mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
test(mcp): add test foundation for the MCP server (#12291)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""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
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared test helpers for the Prowler MCP Server suite.
|
||||
|
||||
Import from the submodules directly (``from tests.helpers.jsonapi import ...``);
|
||||
this package only re-exports the surface so it is discoverable in one place.
|
||||
|
||||
Nothing here is collected by pytest -- ``python_files`` is ``test_*.py``.
|
||||
"""
|
||||
|
||||
from tests.helpers.assertions import (
|
||||
NAMESPACES,
|
||||
assert_namespaced,
|
||||
assert_tool_contract,
|
||||
tools_in_namespace,
|
||||
)
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_document,
|
||||
jsonapi_error,
|
||||
jsonapi_relationship_many,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
task_document,
|
||||
)
|
||||
from tests.helpers.tokens import (
|
||||
FAKE_API_KEY,
|
||||
FAKE_LEGACY_API_KEY,
|
||||
MALFORMED_API_KEY,
|
||||
fake_jwt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FAKE_API_KEY",
|
||||
"FAKE_LEGACY_API_KEY",
|
||||
"MALFORMED_API_KEY",
|
||||
"NAMESPACES",
|
||||
"MockRouter",
|
||||
"assert_namespaced",
|
||||
"assert_tool_contract",
|
||||
"fake_jwt",
|
||||
"jsonapi_collection",
|
||||
"jsonapi_document",
|
||||
"jsonapi_error",
|
||||
"jsonapi_relationship_many",
|
||||
"jsonapi_relationship_one",
|
||||
"jsonapi_resource",
|
||||
"task_document",
|
||||
"tools_in_namespace",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Assertions for the MCP tool contract every sub-server must honour.
|
||||
|
||||
A tool's description and its parameter descriptions are not documentation -- they
|
||||
are the only thing a model sees when deciding whether and how to call it. A tool
|
||||
that registers without them is invisible in practice, so these are correctness
|
||||
assertions rather than style ones.
|
||||
"""
|
||||
|
||||
from mcp.types import Tool
|
||||
|
||||
# Mounted namespaces, most specific first so prefix matching is unambiguous.
|
||||
NAMESPACES = ("prowler_hub_", "prowler_docs_", "prowler_")
|
||||
|
||||
|
||||
def assert_tool_contract(tool: Tool) -> None:
|
||||
"""Assert the tool and all of its parameters carry a usable description.
|
||||
|
||||
Missing and blank are asserted separately because they are different
|
||||
mistakes: a missing description was never written, a blank one exists but was
|
||||
left empty. One truthiness check would report both the same way.
|
||||
"""
|
||||
assert tool.description is not None, (
|
||||
f"Tool '{tool.name}' has no description. Its docstring is what the model reads."
|
||||
)
|
||||
assert tool.description.strip(), (
|
||||
f"Tool '{tool.name}' has a blank description. "
|
||||
"Its docstring is what the model reads."
|
||||
)
|
||||
|
||||
# `inputSchema` is a required field of the MCP Tool type, so it is always a
|
||||
# dict; a tool that takes no arguments simply has no `properties`.
|
||||
for parameter, schema in tool.inputSchema.get("properties", {}).items():
|
||||
description = schema.get("description")
|
||||
assert description is not None, (
|
||||
f"Parameter '{parameter}' of tool '{tool.name}' has no description. "
|
||||
"Declare it with pydantic Field(description=...)."
|
||||
)
|
||||
assert description.strip(), (
|
||||
f"Parameter '{parameter}' of tool '{tool.name}' has a blank description. "
|
||||
"Declare it with pydantic Field(description=...)."
|
||||
)
|
||||
|
||||
|
||||
def assert_namespaced(tool: Tool) -> None:
|
||||
"""Assert the tool is reachable under one of the published namespaces."""
|
||||
assert tool.name.startswith(NAMESPACES), (
|
||||
f"Tool '{tool.name}' is outside the published namespaces {NAMESPACES}"
|
||||
)
|
||||
|
||||
|
||||
def tools_in_namespace(tools: list[Tool], namespace: str) -> list[Tool]:
|
||||
"""Return the tools in a namespace.
|
||||
|
||||
``prowler_`` is a prefix of the other two namespaces, so tools belonging to a
|
||||
more specific one are excluded rather than counted twice.
|
||||
"""
|
||||
more_specific = tuple(
|
||||
other
|
||||
for other in NAMESPACES
|
||||
if other != namespace and other.startswith(namespace)
|
||||
)
|
||||
return [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.name.startswith(namespace) and not tool.name.startswith(more_specific)
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Route registry and request recorder backed by ``httpx.MockTransport``.
|
||||
|
||||
Mocking at the transport boundary rather than stubbing ``client.request`` keeps
|
||||
the parts of httpx the code under test actually relies on in play: base-URL
|
||||
joining, query-parameter encoding, header assembly, ``raise_for_status()`` and
|
||||
JSON decoding. A test that asserts on a recorded request is therefore asserting
|
||||
on the bytes that would really have gone out.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
ResponseFactory = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
class MockRouter:
|
||||
"""Declare ``(METHOD, path) -> response`` and inspect what was requested.
|
||||
|
||||
Responses registered for the same route are consumed in order and the last
|
||||
one repeats forever. That is what makes polling testable: register
|
||||
``executing``, ``executing``, ``completed`` and the loop sees each in turn.
|
||||
|
||||
An unregistered request raises instead of returning a default, so a test can
|
||||
never silently exercise a different endpoint than the one it set up.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._routes: dict[tuple[str, str], list[ResponseFactory]] = {}
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
# --- registration -----------------------------------------------------
|
||||
|
||||
def add(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
status: int = 200,
|
||||
json: Any = _UNSET,
|
||||
text: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> "MockRouter":
|
||||
"""Register a canned response for a route. Chainable."""
|
||||
kwargs: dict[str, Any] = {"headers": headers}
|
||||
if json is not _UNSET:
|
||||
kwargs["json"] = json
|
||||
if text is not None:
|
||||
kwargs["text"] = text
|
||||
return self.add_handler(
|
||||
method, path, lambda _request: httpx.Response(status, **kwargs)
|
||||
)
|
||||
|
||||
def add_handler(
|
||||
self, method: str, path: str, handler: ResponseFactory
|
||||
) -> "MockRouter":
|
||||
"""Register a callable that builds the response from the request."""
|
||||
self._routes.setdefault((method.upper(), path), []).append(handler)
|
||||
return self
|
||||
|
||||
# --- transport --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
"""A transport that serves this router. Works for sync and async clients."""
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
queue = self._routes.get((request.method.upper(), request.url.path))
|
||||
if not queue:
|
||||
registered = (
|
||||
", ".join(f"{method} {path}" for method, path in sorted(self._routes))
|
||||
or "none"
|
||||
)
|
||||
raise AssertionError(
|
||||
f"Unregistered request {request.method} {request.url}. "
|
||||
f"Registered routes: {registered}"
|
||||
)
|
||||
# Keep the final response so a route can be polled repeatedly.
|
||||
factory = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
return factory(request)
|
||||
|
||||
# --- inspection -------------------------------------------------------
|
||||
|
||||
def request_for(self, method: str, path: str) -> httpx.Request:
|
||||
"""Return the last recorded request for a route, failing if there is none."""
|
||||
matches = [
|
||||
request
|
||||
for request in self.requests
|
||||
if request.method.upper() == method.upper() and request.url.path == path
|
||||
]
|
||||
if not matches:
|
||||
raise AssertionError(
|
||||
f"No {method.upper()} {path} request was made. Made: {self.paths()}"
|
||||
)
|
||||
return matches[-1]
|
||||
|
||||
def query_params(self, method: str, path: str) -> dict[str, str]:
|
||||
"""Return the decoded query parameters of the last request for a route."""
|
||||
return dict(self.request_for(method, path).url.params)
|
||||
|
||||
def paths(self) -> list[str]:
|
||||
"""Return every request made so far, as ``"METHOD /path"`` strings."""
|
||||
return [f"{request.method} {request.url.path}" for request in self.requests]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Builders for the JSON:API documents the Prowler API returns.
|
||||
|
||||
Every model's ``from_api_response()`` and every tool's error path consumes one of
|
||||
these shapes, so building them by hand in each test would duplicate the document
|
||||
structure hundreds of times. The builders keep the *shape* in one place so tests
|
||||
only express the part they actually care about.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def jsonapi_relationship_many(resource_type: str, *ids: str) -> dict[str, Any]:
|
||||
"""Build a to-many relationship.
|
||||
|
||||
Passing no ids yields a present-but-empty relationship (``{"data": []}``),
|
||||
which ``extract_relationship_ids`` reports as ``[]`` rather than ``None``.
|
||||
"""
|
||||
return {"data": [{"type": resource_type, "id": resource_id} for resource_id in ids]}
|
||||
|
||||
|
||||
def jsonapi_relationship_one(resource_type: str, resource_id: str) -> dict[str, Any]:
|
||||
"""Build a to-one relationship."""
|
||||
return {"data": {"type": resource_type, "id": resource_id}}
|
||||
|
||||
|
||||
def jsonapi_resource(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
relationships: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a single JSON:API resource object.
|
||||
|
||||
``relationships`` is omitted from the result entirely when not supplied, so a
|
||||
test can express "the document did not expose this relationship"
|
||||
(``extract_relationship_ids`` -> ``None``) distinctly from "the relationship
|
||||
is present and empty" (-> ``[]``). Conflating the two is exactly the bug the
|
||||
models go out of their way to avoid.
|
||||
"""
|
||||
resource: dict[str, Any] = {
|
||||
"type": resource_type,
|
||||
"id": resource_id,
|
||||
"attributes": attributes or {},
|
||||
}
|
||||
if relationships is not None:
|
||||
resource["relationships"] = relationships
|
||||
return resource
|
||||
|
||||
|
||||
def jsonapi_document(
|
||||
data: dict[str, Any] | list[dict[str, Any]],
|
||||
included: list[dict[str, Any]] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a top-level JSON:API document."""
|
||||
document: dict[str, Any] = {"data": data}
|
||||
if included is not None:
|
||||
document["included"] = included
|
||||
if meta is not None:
|
||||
document["meta"] = meta
|
||||
return document
|
||||
|
||||
|
||||
def jsonapi_collection(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
page: int = 1,
|
||||
pages: int = 1,
|
||||
count: int | None = None,
|
||||
included: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a paginated collection document.
|
||||
|
||||
The ``meta.pagination`` keys are exactly the ones every ``*ListResponse``
|
||||
reads (``page``, ``pages``, ``count``). ``count`` defaults to the number of
|
||||
items so the common single-page case needs no arguments.
|
||||
"""
|
||||
return jsonapi_document(
|
||||
data=items,
|
||||
included=included,
|
||||
meta={
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"count": len(items) if count is None else count,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def jsonapi_error(status: int, detail: str, title: str | None = None) -> dict[str, Any]:
|
||||
"""Build an error document.
|
||||
|
||||
``ProwlerAPIClient._make_request`` surfaces ``errors[0].detail`` in the
|
||||
exception message it raises, and tools relay that straight to the model.
|
||||
"""
|
||||
error: dict[str, Any] = {"status": str(status), "detail": detail}
|
||||
if title is not None:
|
||||
error["title"] = title
|
||||
return {"errors": [error]}
|
||||
|
||||
|
||||
def task_document(task_id: str, state: str, error: str | None = None) -> dict[str, Any]:
|
||||
"""Build a ``/tasks/{id}`` document for driving ``poll_task_until_complete``.
|
||||
|
||||
Register a sequence of these on a ``MockRouter`` route (for example
|
||||
``executing``, ``executing``, ``completed``) to exercise the polling loop.
|
||||
"""
|
||||
attributes: dict[str, Any] = {"state": state}
|
||||
if error is not None:
|
||||
attributes["error"] = error
|
||||
return jsonapi_document(jsonapi_resource("tasks", task_id, attributes))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Obviously-fake credentials for tests.
|
||||
|
||||
Deliberately unrealistic so repository secret scanning does not flag them. Never
|
||||
put a value here that could be mistaken for a real key.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
# Prowler API keys are recognised by their `pk_` prefix; anything else is rejected.
|
||||
FAKE_API_KEY = "pk_fake_api_key_for_unit_testing_only"
|
||||
FAKE_LEGACY_API_KEY = "pk_fake_legacy_api_key_for_unit_testing_only"
|
||||
MALFORMED_API_KEY = "not_a_prowler_api_key"
|
||||
|
||||
|
||||
def fake_jwt(expires_in: int = 3600, **claims: object) -> str:
|
||||
"""Mint an unsigned JWT whose ``exp`` is ``expires_in`` seconds from now.
|
||||
|
||||
Pass a negative ``expires_in`` for an already-expired token.
|
||||
|
||||
``ProwlerAppAuth._parse_jwt`` only base64url-decodes the payload and reads
|
||||
``exp`` -- it never verifies the signature, because the Prowler API is what
|
||||
validates the token. A placeholder signature is therefore enough, and avoids
|
||||
adding a JWT library just for tests.
|
||||
"""
|
||||
|
||||
def _segment(payload: dict[str, object]) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
header = _segment({"alg": "HS256", "typ": "JWT"})
|
||||
body = _segment({"exp": int(time.time()) + expires_in, **claims})
|
||||
return f"{header}.{body}.fake-signature-not-verified"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App sub-server."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App Pydantic models."""
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for the security finding models.
|
||||
|
||||
Reference for later branches: build the API document with the ``jsonapi``
|
||||
helpers, run it through ``from_api_response()``, then assert on both the model
|
||||
and its ``model_dump()``. The dump is what the agent actually receives, and
|
||||
``MinimalSerializerMixin`` makes the two differ.
|
||||
"""
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.findings import (
|
||||
DetailedFinding,
|
||||
FindingsListResponse,
|
||||
FindingsOverview,
|
||||
SimplifiedFinding,
|
||||
)
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_relationship_many,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
CHECK_METADATA = {
|
||||
"checkid": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets block public access",
|
||||
"description": "Checks whether the bucket blocks public access.",
|
||||
"provider": "aws",
|
||||
"servicename": "s3",
|
||||
"resourcetype": "AwsS3Bucket",
|
||||
"risk": "Public buckets expose data to the internet.",
|
||||
"additionalurls": ["https://docs.aws.amazon.com/s3/"],
|
||||
"categories": ["encryption", "internet-exposed"],
|
||||
}
|
||||
|
||||
FINDING_ATTRIBUTES = {
|
||||
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"status_extended": "S3 bucket my-bucket is publicly accessible.",
|
||||
"delta": "new",
|
||||
"muted": False,
|
||||
"muted_reason": None,
|
||||
"check_metadata": CHECK_METADATA,
|
||||
}
|
||||
|
||||
DETAILED_ATTRIBUTES = {
|
||||
**FINDING_ATTRIBUTES,
|
||||
"inserted_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
"first_seen_at": "2025-01-10T09:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def test_simplified_finding_lifts_the_check_id_out_of_the_check_metadata():
|
||||
"""`check_id` is nested under `check_metadata.checkid` in the API document.
|
||||
|
||||
Flattening it is what lets an agent filter findings by check without being
|
||||
handed the whole metadata blob for every row in a list.
|
||||
"""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.check_id == "s3_bucket_public_access"
|
||||
assert finding.severity == "high"
|
||||
assert finding.status == "FAIL"
|
||||
|
||||
|
||||
def test_empty_finding_fields_are_dropped_from_the_serialized_payload():
|
||||
"""Empty values are removed to keep the payload small for the model.
|
||||
|
||||
`muted_reason` is None on an unmuted finding; emitting it would spend tokens
|
||||
on every row of every list response to say nothing.
|
||||
"""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
dumped = finding.model_dump()
|
||||
|
||||
assert "muted_reason" not in dumped
|
||||
assert dumped["uid"] == FINDING_ATTRIBUTES["uid"]
|
||||
|
||||
|
||||
def test_detailed_finding_parses_both_relationship_shapes():
|
||||
"""`scan` is a to-one relationship and `resources` is to-many.
|
||||
|
||||
They are read from the same `relationships` object but reduce to a single id
|
||||
and a list of ids respectively.
|
||||
"""
|
||||
resource = jsonapi_resource(
|
||||
"findings",
|
||||
"f1",
|
||||
attributes=DETAILED_ATTRIBUTES,
|
||||
relationships={
|
||||
"scan": jsonapi_relationship_one("scans", "s1"),
|
||||
"resources": jsonapi_relationship_many("resources", "r1", "r2"),
|
||||
},
|
||||
)
|
||||
|
||||
finding = DetailedFinding.from_api_response(resource)
|
||||
|
||||
assert finding.scan_id == "s1"
|
||||
assert finding.resource_ids == ["r1", "r2"]
|
||||
|
||||
|
||||
def test_detailed_finding_tolerates_missing_relationships():
|
||||
"""A document without relationships must not raise.
|
||||
|
||||
`get_finding_details` requests `include=scan,resources`, but a finding whose
|
||||
scan has been pruned still has to render rather than fail the tool call.
|
||||
"""
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.scan_id is None
|
||||
assert finding.resource_ids == []
|
||||
|
||||
|
||||
def test_detailed_finding_flattens_the_nested_remediation_guidance():
|
||||
"""Remediation is the payload an agent needs to actually fix the finding.
|
||||
|
||||
The API nests it under `remediation.code.*` and `remediation.recommendation.text`;
|
||||
the model flattens both into one object.
|
||||
"""
|
||||
attributes = {
|
||||
**DETAILED_ATTRIBUTES,
|
||||
"check_metadata": {
|
||||
**CHECK_METADATA,
|
||||
"remediation": {
|
||||
"code": {
|
||||
"cli": "aws s3api put-public-access-block ...",
|
||||
"terraform": 'resource "aws_s3_bucket_public_access_block" ...',
|
||||
"nativeiac": "",
|
||||
"other": "",
|
||||
},
|
||||
"recommendation": {"text": "Block all public access on the bucket."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", attributes)
|
||||
)
|
||||
|
||||
remediation = finding.check_metadata.remediation
|
||||
assert remediation.cli.startswith("aws s3api")
|
||||
assert remediation.recommendation == "Block all public access on the bucket."
|
||||
# Empty code snippets are dropped rather than shown as blank fields.
|
||||
assert "nativeiac" not in remediation.model_dump()
|
||||
|
||||
|
||||
def test_check_metadata_without_remediation_is_left_unset():
|
||||
"""Not every check ships remediation guidance; absence must not fabricate one."""
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.check_metadata.remediation is None
|
||||
assert "remediation" not in finding.check_metadata.model_dump()
|
||||
|
||||
|
||||
def test_list_response_carries_the_api_pagination_metadata():
|
||||
"""Pagination tells an agent whether it has seen everything it asked for."""
|
||||
response = jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)],
|
||||
page=2,
|
||||
pages=7,
|
||||
count=312,
|
||||
)
|
||||
|
||||
result = FindingsListResponse.from_api_response(response)
|
||||
|
||||
assert result.current_page == 2
|
||||
assert result.total_num_pages == 7
|
||||
assert result.total_num_finding == 312
|
||||
assert result.findings[0].check_id == "s3_bucket_public_access"
|
||||
|
||||
|
||||
def test_overview_renames_the_pass_attribute_to_a_valid_identifier():
|
||||
"""The API's `pass` count cannot keep its name -- `pass` is a Python keyword."""
|
||||
response = jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
{
|
||||
"total": 100,
|
||||
"fail": 30,
|
||||
"pass": 60,
|
||||
"muted": 10,
|
||||
"new": 5,
|
||||
"changed": 3,
|
||||
"fail_new": 2,
|
||||
"fail_changed": 1,
|
||||
"pass_new": 2,
|
||||
"pass_changed": 1,
|
||||
"muted_new": 1,
|
||||
"muted_changed": 1,
|
||||
},
|
||||
)
|
||||
|
||||
overview = FindingsOverview.from_api_response({"data": response})
|
||||
|
||||
assert overview.passed == 60
|
||||
assert overview.fail == 30
|
||||
assert overview.total == 100
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for the shared JSON:API response-parsing helpers.
|
||||
|
||||
These back every model's ``from_api_response()``, so they are foundation-level
|
||||
rather than tied to any one feature.
|
||||
"""
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids
|
||||
from tests.helpers.jsonapi import jsonapi_relationship_many, jsonapi_relationship_one
|
||||
|
||||
|
||||
def test_an_absent_relationship_is_unknown_rather_than_empty():
|
||||
"""A relationship the document never mentioned yields None, not [].
|
||||
|
||||
Returning [] would tell an agent "this role is assigned to nobody" when the
|
||||
serializer simply did not expose the relationship -- for example a role
|
||||
included via `?include=roles`, which carries no `users`.
|
||||
"""
|
||||
assert extract_relationship_ids({}, "users") is None
|
||||
|
||||
|
||||
def test_a_present_but_empty_relationship_is_explicitly_empty():
|
||||
"""An empty relationship yields [], which genuinely means "none"."""
|
||||
relationships = {"users": jsonapi_relationship_many("users")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == []
|
||||
|
||||
|
||||
def test_a_to_many_relationship_is_flattened_to_its_ids():
|
||||
"""Linkage objects are reduced to the plain ids the tools pass around."""
|
||||
relationships = {"users": jsonapi_relationship_many("users", "u1", "u2")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == ["u1", "u2"]
|
||||
|
||||
|
||||
def test_a_to_one_relationship_is_returned_as_a_single_element_list():
|
||||
"""To-one and to-many both return a list so callers need no shape check."""
|
||||
relationships = {"scan": jsonapi_relationship_one("scans", "s1")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "scan") == ["s1"]
|
||||
|
||||
|
||||
def test_a_null_to_one_relationship_is_empty():
|
||||
"""An explicitly null to-one link means "not related", not "unknown"."""
|
||||
relationships = {"scan": {"data": None}}
|
||||
|
||||
assert extract_relationship_ids(relationships, "scan") == []
|
||||
|
||||
|
||||
def test_members_without_an_id_are_discarded():
|
||||
"""Malformed linkage must not surface as a None entry in the id list.
|
||||
|
||||
A None id would flow into a tool's next request and produce a confusing
|
||||
404 rather than a clean, short list.
|
||||
"""
|
||||
relationships = {"users": {"data": [{"type": "users", "id": "u1"}, {}]}}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == ["u1"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App MCP tools."""
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Tests for the security findings tools.
|
||||
|
||||
Reference for later branches. Drive tools through an in-memory MCP client by
|
||||
default. Tool parameters are declared with pydantic ``Field(default=...)``, and
|
||||
those defaults are only resolved by FastMCP's tool wrapper -- calling the method
|
||||
directly leaves an omitted argument as a raw ``FieldInfo`` object, which is
|
||||
truthy and silently produces nonsense filters. Call the method directly only when
|
||||
passing every argument explicitly.
|
||||
|
||||
Everything here relies on ``mock_api_client`` patching the API client *in place*:
|
||||
the tool instances captured that exact object when the package was imported, so a
|
||||
freshly-constructed client would not reach them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_error,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
LATEST = "/api/v1/findings/latest"
|
||||
HISTORICAL = "/api/v1/findings"
|
||||
|
||||
CHECK_METADATA = {
|
||||
"checkid": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets block public access",
|
||||
"description": "Checks whether the bucket blocks public access.",
|
||||
"provider": "aws",
|
||||
"servicename": "s3",
|
||||
"resourcetype": "AwsS3Bucket",
|
||||
"risk": "Public buckets expose data to the internet.",
|
||||
"additionalurls": [],
|
||||
"categories": ["internet-exposed"],
|
||||
}
|
||||
|
||||
FINDING_ATTRIBUTES = {
|
||||
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"status_extended": "S3 bucket my-bucket is publicly accessible.",
|
||||
"delta": "new",
|
||||
"muted": False,
|
||||
"muted_reason": None,
|
||||
"check_metadata": CHECK_METADATA,
|
||||
}
|
||||
|
||||
|
||||
async def test_search_without_dates_queries_the_latest_scan_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""With no date range the tool targets `/findings/latest`.
|
||||
|
||||
That endpoint reads only the most recent completed scan, which is far cheaper
|
||||
than a historical query -- so picking the wrong one is a performance
|
||||
regression the response body alone would not reveal.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
LATEST,
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
|
||||
),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_search_security_findings", {})
|
||||
|
||||
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
|
||||
assert mock_router.paths() == [f"GET {LATEST}"]
|
||||
|
||||
|
||||
async def test_search_defaults_to_failed_findings_only(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The default filter is FAIL, so an unqualified search surfaces real issues.
|
||||
|
||||
Also pins the sort order and field selection, which together keep the
|
||||
response small and severity-first.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {})
|
||||
|
||||
params = mock_router.query_params("GET", LATEST)
|
||||
assert params["filter[status__in]"] == "FAIL"
|
||||
assert params["sort"] == "severity,-inserted_at"
|
||||
assert params["page[size]"] == "50"
|
||||
|
||||
|
||||
async def test_search_with_dates_switches_to_the_historical_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A date range moves the query to `/findings` with an inserted_at window.
|
||||
|
||||
Supplying only `date_from` auto-completes the other boundary, so the caller
|
||||
cannot accidentally request an unbounded historical scan.
|
||||
"""
|
||||
mock_router.add("GET", HISTORICAL, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings", {"date_from": "2025-01-15"}
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", HISTORICAL)
|
||||
assert params["filter[inserted_at__gte]"] == "2025-01-15"
|
||||
assert params["filter[inserted_at__lte]"] == "2025-01-16"
|
||||
|
||||
|
||||
async def test_search_rejects_a_date_range_wider_than_the_api_allows(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The API caps historical queries at two days; reject before the round trip."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Date range cannot exceed 2 days"):
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings",
|
||||
{"date_from": "2025-01-01", "date_to": "2025-01-10"},
|
||||
)
|
||||
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_search_encodes_list_filters_as_comma_separated_values(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Multi-value filters reach the API as CSV, not as repeated query keys."""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings",
|
||||
{"severity": ["critical", "high"], "service": ["s3", "ec2"]},
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", LATEST)
|
||||
assert params["filter[severity__in]"] == "critical,high"
|
||||
assert params["filter[service__in]"] == "s3,ec2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argument", "value", "expected_key", "expected_value"),
|
||||
[
|
||||
("provider_type", ["aws", "gcp"], "filter[provider_type__in]", "aws,gcp"),
|
||||
("provider_alias", "prod", "filter[provider_alias__icontains]", "prod"),
|
||||
("region", ["us-east-1"], "filter[region__in]", "us-east-1"),
|
||||
("resource_type", ["AwsS3Bucket"], "filter[resource_type__in]", "AwsS3Bucket"),
|
||||
(
|
||||
"check_id",
|
||||
["s3_bucket_public_access"],
|
||||
"filter[check_id__in]",
|
||||
"s3_bucket_public_access",
|
||||
),
|
||||
("delta", ["new"], "filter[delta__in]", "new"),
|
||||
("search", "bucket", "filter[search]", "bucket"),
|
||||
],
|
||||
)
|
||||
async def test_search_maps_each_argument_onto_its_api_filter(
|
||||
mcp_root_server,
|
||||
mock_api_client,
|
||||
mock_router,
|
||||
argument,
|
||||
value,
|
||||
expected_key,
|
||||
expected_value,
|
||||
):
|
||||
"""Every search argument maps to a specific API filter key.
|
||||
|
||||
A mistyped filter key is not an error the API reports -- it is simply ignored,
|
||||
so the tool returns unfiltered results while appearing to work. Pinning the
|
||||
exact key per argument is the only thing that catches that.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {argument: value})
|
||||
|
||||
assert mock_router.query_params("GET", LATEST)[expected_key] == expected_value
|
||||
|
||||
|
||||
async def test_overview_can_be_scoped_to_a_provider(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The aggregate report accepts the same provider filter as the search tool."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/overviews/findings",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
dict.fromkeys(
|
||||
[
|
||||
"total",
|
||||
"fail",
|
||||
"pass",
|
||||
"muted",
|
||||
"new",
|
||||
"changed",
|
||||
"fail_new",
|
||||
"fail_changed",
|
||||
"pass_new",
|
||||
"pass_changed",
|
||||
"muted_new",
|
||||
"muted_changed",
|
||||
],
|
||||
0,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_get_findings_overview", {"provider_type": ["aws"]}
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", "/api/v1/overviews/findings")
|
||||
assert params["filter[provider_type__in]"] == "aws"
|
||||
|
||||
|
||||
async def test_search_normalises_a_string_muted_flag_to_a_boolean(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""`muted` accepts a string because some MCP clients send booleans as text.
|
||||
|
||||
It still has to reach the API as a lowercase boolean, otherwise the filter is
|
||||
silently ignored and the agent gets muted findings it asked to exclude.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {"muted": "true"})
|
||||
|
||||
assert mock_router.query_params("GET", LATEST)["filter[muted]"] == "true"
|
||||
|
||||
|
||||
async def test_search_rejects_an_out_of_range_page_size(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Page size is validated locally, saving a round trip on an obvious mistake."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Must be between 1 and 1000"):
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings", {"page_size": 5000}
|
||||
)
|
||||
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_get_finding_details_requests_its_relationships(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Details are only useful with the scan and resources included.
|
||||
|
||||
Dropping the `include` would leave `scan_id` and `resource_ids` empty and the
|
||||
agent unable to pivot from a finding to the resource it concerns.
|
||||
"""
|
||||
attributes = {
|
||||
**FINDING_ATTRIBUTES,
|
||||
"inserted_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
}
|
||||
mock_router.add(
|
||||
"GET",
|
||||
f"{HISTORICAL}/f1",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings",
|
||||
"f1",
|
||||
attributes,
|
||||
relationships={"scan": jsonapi_relationship_one("scans", "s1")},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_get_finding_details", {"finding_id": "f1"}
|
||||
)
|
||||
|
||||
assert result.data["scan_id"] == "s1"
|
||||
assert mock_router.query_params("GET", f"{HISTORICAL}/f1")["include"] == (
|
||||
"scan,resources"
|
||||
)
|
||||
|
||||
|
||||
async def test_get_finding_details_surfaces_the_api_error_detail(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A missing finding surfaces the API's message rather than an opaque failure."""
|
||||
mock_router.add(
|
||||
"GET", f"{HISTORICAL}/nope", status=404, json=jsonapi_error(404, "Not found.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Not found."):
|
||||
await client.call_tool(
|
||||
"prowler_get_finding_details", {"finding_id": "nope"}
|
||||
)
|
||||
|
||||
|
||||
async def test_overview_renders_a_markdown_report_with_percentages(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The overview returns prose, not a model, so the arithmetic is the contract.
|
||||
|
||||
Percentages are derived here rather than by the API, which makes them the one
|
||||
part of this tool that can silently go wrong.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/overviews/findings",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
{
|
||||
"total": 200,
|
||||
"fail": 50,
|
||||
"pass": 130,
|
||||
"muted": 20,
|
||||
"new": 10,
|
||||
"changed": 4,
|
||||
"fail_new": 6,
|
||||
"fail_changed": 2,
|
||||
"pass_new": 3,
|
||||
"pass_changed": 1,
|
||||
"muted_new": 1,
|
||||
"muted_changed": 1,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_get_findings_overview", {})
|
||||
|
||||
report = result.data["report"]
|
||||
assert "**Total Findings**: 200" in report
|
||||
assert "**Failed Checks**: 50 (25.0%)" in report
|
||||
assert "**Unchanged**: 186" in report
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App shared utilities."""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the shared Prowler API client.
|
||||
|
||||
Reference for later branches: drive the client through ``mock_api_client`` +
|
||||
``mock_router`` and assert on the recorded request, so the real URL joining,
|
||||
query encoding and header assembly stay covered.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
|
||||
from tests.helpers.tokens import FAKE_API_KEY
|
||||
|
||||
|
||||
async def test_get_sends_an_authenticated_jsonapi_request(mock_api_client, mock_router):
|
||||
"""A GET carries the API key and the JSON:API content negotiation headers."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings",
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", {"severity": "high"})]
|
||||
),
|
||||
)
|
||||
|
||||
await mock_api_client.get("/findings")
|
||||
|
||||
request = mock_router.request_for("GET", "/api/v1/findings")
|
||||
assert request.headers["authorization"] == f"Api-Key {FAKE_API_KEY}"
|
||||
assert request.headers["accept"] == "application/vnd.api+json"
|
||||
assert request.headers["user-agent"].startswith("prowler-mcp-server/")
|
||||
|
||||
|
||||
async def test_get_forwards_query_parameters(mock_api_client, mock_router):
|
||||
"""Filter parameters reach the wire with their JSON:API bracket syntax intact."""
|
||||
mock_router.add("GET", "/api/v1/findings", json=jsonapi_collection([]))
|
||||
|
||||
await mock_api_client.get(
|
||||
"/findings", params={"page[size]": 5, "filter[severity__in]": "critical"}
|
||||
)
|
||||
|
||||
assert mock_router.query_params("GET", "/api/v1/findings") == {
|
||||
"page[size]": "5",
|
||||
"filter[severity__in]": "critical",
|
||||
}
|
||||
|
||||
|
||||
async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router):
|
||||
"""A failed request is raised with the API's own `errors[].detail` message.
|
||||
|
||||
Tools relay this text straight to the model, so losing it turns an actionable
|
||||
error into an opaque one.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings/nope",
|
||||
status=404,
|
||||
json=jsonapi_error(404, "Not found."),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match=r"API request failed: 404 - Not found\."):
|
||||
await mock_api_client.get("/findings/nope")
|
||||
|
||||
|
||||
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
|
||||
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""
|
||||
result = mock_api_client.build_filter_params(
|
||||
{
|
||||
"filter[muted]": True,
|
||||
"filter[severity__in]": ["high", "critical"],
|
||||
"filter[status]": None,
|
||||
"page[size]": 50,
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"filter[muted]": "true",
|
||||
"filter[severity__in]": "high,critical",
|
||||
"page[size]": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_the_api_client_is_a_singleton(isolated_api_client):
|
||||
"""Every tool must share one client so the HTTP connection pool is shared."""
|
||||
assert isolated_api_client() is isolated_api_client()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for Prowler API authentication.
|
||||
|
||||
Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode`` and
|
||||
``base_url`` in default arguments, which Python evaluates once at module import.
|
||||
``monkeypatch.setenv`` therefore has no effect on them -- always pass ``mode=``
|
||||
and ``base_url=`` explicitly, as these tests do.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
|
||||
from tests.helpers.tokens import FAKE_API_KEY, MALFORMED_API_KEY, fake_jwt
|
||||
|
||||
|
||||
async def test_stdio_mode_reads_the_api_key_from_the_environment():
|
||||
"""In STDIO transport the key comes from the process environment."""
|
||||
auth = ProwlerAppAuth(mode="stdio")
|
||||
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
def test_stdio_mode_rejects_a_key_without_the_prowler_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A key that is not `pk_`-prefixed is refused at construction.
|
||||
|
||||
Failing here rather than on the first API call is what turns a
|
||||
misconfiguration into an immediate, readable startup error.
|
||||
"""
|
||||
monkeypatch.setenv("PROWLER_API_KEY", MALFORMED_API_KEY)
|
||||
|
||||
with pytest.raises(ValueError, match="Prowler API key format is incorrect"):
|
||||
ProwlerAppAuth(mode="stdio")
|
||||
|
||||
|
||||
async def test_http_mode_accepts_a_bearer_api_key(http_request_headers):
|
||||
"""In HTTP transport the token comes from the request's Authorization header."""
|
||||
http_request_headers(authorization=f"Bearer {FAKE_API_KEY}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
async def test_http_mode_rejects_an_expired_jwt(http_request_headers):
|
||||
"""An expired JWT is refused locally instead of being forwarded to the API."""
|
||||
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(ValueError, match="Token has expired"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
def test_api_keys_and_jwts_use_different_authorization_schemes():
|
||||
"""Prowler API keys authenticate with `Api-Key`, JWTs with `Bearer`."""
|
||||
auth = ProwlerAppAuth(mode="stdio")
|
||||
|
||||
assert auth.get_headers(FAKE_API_KEY)["Authorization"] == f"Api-Key {FAKE_API_KEY}"
|
||||
|
||||
jwt = fake_jwt()
|
||||
assert auth.get_headers(jwt)["Authorization"] == f"Bearer {jwt}"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler Documentation sub-server."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler Hub sub-server."""
|
||||
@@ -1,16 +1,11 @@
|
||||
"""Tests for the Prowler MCP Server health endpoint."""
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.server import app
|
||||
|
||||
|
||||
def test_health_returns_ietf_pass_response():
|
||||
def test_health_returns_ietf_pass_response(health_client):
|
||||
"""GET /health returns 200 with the IETF health-check body and headers."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
response = health_client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/health+json"
|
||||
@@ -24,23 +19,19 @@ def test_health_returns_ietf_pass_response():
|
||||
}
|
||||
|
||||
|
||||
def test_health_release_id_matches_package_version():
|
||||
def test_health_release_id_matches_package_version(health_client):
|
||||
"""The endpoint must surface the current package __version__ as releaseId.
|
||||
|
||||
Drift between the response and the installed package would mislead any
|
||||
monitoring tool that uses releaseId to identify the running build.
|
||||
"""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
response = health_client.get("/health")
|
||||
|
||||
assert response.json()["releaseId"] == __version__
|
||||
|
||||
|
||||
def test_health_rejects_non_get_methods():
|
||||
def test_health_rejects_non_get_methods(health_client):
|
||||
"""The endpoint only exposes GET; other verbs return 405."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/health")
|
||||
response = health_client.post("/health")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the mounted root MCP server.
|
||||
|
||||
Reference for later branches: open the client inline with
|
||||
``async with Client(mcp_root_server)``. FastMCP warns against holding a client in
|
||||
a fixture because it causes hard-to-diagnose event-loop problems.
|
||||
"""
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.assertions import (
|
||||
assert_namespaced,
|
||||
assert_tool_contract,
|
||||
tools_in_namespace,
|
||||
)
|
||||
|
||||
|
||||
async def test_every_sub_server_contributes_tools(mcp_root_server):
|
||||
"""Each of the three mounts must expose tools under its own namespace.
|
||||
|
||||
This is the guard against a silent startup failure. ``setup_main_server()``
|
||||
wraps each mount in try/except and ``load_all_tools`` swallows per-tool
|
||||
construction errors, so a sub-server that registers nothing is still logged as
|
||||
"successfully mounted". The `prowler_*` namespace in particular collapses to
|
||||
zero tools whenever the API key is missing when the module is first imported.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
assert tools_in_namespace(tools, "prowler_hub_"), "Prowler Hub registered no tools"
|
||||
assert tools_in_namespace(tools, "prowler_docs_"), (
|
||||
"Prowler Docs registered no tools"
|
||||
)
|
||||
assert tools_in_namespace(tools, "prowler_"), "Prowler App registered no tools"
|
||||
|
||||
|
||||
async def test_every_tool_is_namespaced(mcp_root_server):
|
||||
"""Tool names are a published interface; nothing may escape the namespaces."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
assert_namespaced(tool)
|
||||
|
||||
|
||||
async def test_every_tool_and_parameter_is_described(mcp_root_server):
|
||||
"""Descriptions are the contract a model reads before calling a tool."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
assert_tool_contract(tool)
|
||||
Reference in New Issue
Block a user