test(mcp): add test foundation for the MCP server (#12291)

This commit is contained in:
Rubén De la Torre Vico
2026-08-04 16:19:10 +02:00
committed by GitHub
parent c74eac1369
commit 138d643119
32 changed files with 2332 additions and 22 deletions
+264
View File
@@ -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
+49
View File
@@ -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",
]
+66
View File
@@ -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)
]
+108
View File
@@ -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]
+112
View File
@@ -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))
+34
View File
@@ -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"
+1
View File
@@ -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."""
+1
View File
@@ -0,0 +1 @@
"""Tests for the Prowler Hub sub-server."""
+6 -15
View File
@@ -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
+51
View File
@@ -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)