mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
test(mcp): add test foundation for the MCP server (#12291)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
name: 'MCP: Tests'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
MCP_WORKING_DIR: ./mcp_server
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
mcp-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
# requires-python is >=3.12 while the shipped image is 3.13; testing both
|
||||
# is what keeps that floor honest.
|
||||
python-version:
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./mcp_server
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
|
||||
# the suite mocks every outbound call, so a real one must fail the job.
|
||||
# The sentry.io entry is not the test suite: the Codecov uploader sends
|
||||
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
cli.codecov.io:443
|
||||
keybase.io:443
|
||||
ingest.codecov.io:443
|
||||
o26192.ingest.us.sentry.io:443
|
||||
storage.googleapis.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for MCP server changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
mcp_server/**
|
||||
.github/workflows/mcp-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
mcp_server/changelog.d/**
|
||||
mcp_server/AGENTS.md
|
||||
mcp_server/Dockerfile
|
||||
mcp_server/.dockerignore
|
||||
mcp_server/entrypoint.sh
|
||||
|
||||
- name: Setup Python with uv
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
working-directory: ./mcp_server
|
||||
|
||||
- name: Run tests with pytest
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: mcp
|
||||
@@ -34,6 +34,9 @@ test: ## Test with pytest
|
||||
rm -rf .coverage && \
|
||||
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
|
||||
|
||||
test-mcp: ## Test MCP server with pytest (mirrors CI)
|
||||
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
|
||||
|
||||
coverage: ## Show Test Coverage
|
||||
coverage run --skip-covered -m pytest -v && \
|
||||
coverage report -m && \
|
||||
|
||||
@@ -6,12 +6,19 @@ component_management:
|
||||
- component_id: "api"
|
||||
paths:
|
||||
- "api/**"
|
||||
- component_id: "mcp_server"
|
||||
paths:
|
||||
- "mcp_server/**"
|
||||
|
||||
flags:
|
||||
api:
|
||||
paths:
|
||||
- "api/**"
|
||||
carryforward: true
|
||||
mcp:
|
||||
paths:
|
||||
- "mcp_server/**"
|
||||
carryforward: true
|
||||
|
||||
comment:
|
||||
layout: "header, diff, flags, components"
|
||||
|
||||
@@ -426,6 +426,150 @@ For complete installation and deployment options, see:
|
||||
|
||||
For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `mcp_server/tests/`, mirroring the source tree, and use the `test_*.py`
|
||||
prefix (the same convention as the API, not the SDK's `*_test.py` suffix).
|
||||
|
||||
From `mcp_server/`:
|
||||
|
||||
```bash
|
||||
cd mcp_server
|
||||
|
||||
uv run pytest # Whole suite
|
||||
uv run pytest tests/prowler_app/models # One area
|
||||
uv run pytest --cov=./prowler_mcp_server # With coverage
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make test-mcp # Runs the MCP suite exactly as CI does
|
||||
```
|
||||
|
||||
Async tests need no marker — `asyncio_mode` is set to `auto`.
|
||||
|
||||
### Reading the Coverage Numbers
|
||||
|
||||
<Warning>
|
||||
Coverage here has a high floor that means nothing. `coverage.py` measures
|
||||
*statements*, and in a Pydantic model module nearly every statement is a class-body
|
||||
field declaration that runs at **import** time. `prowler_app/server.py` imports
|
||||
every tool module — and therefore every model module — when it is first imported,
|
||||
so all of those declarations execute and count as covered before a single test runs.
|
||||
|
||||
Importing the package and executing no tests at all already reports **36% overall**,
|
||||
with individual model modules between 54% and 84%. A model module sitting at ~68%
|
||||
with no tests written for it has **none** of its behaviour covered: the covered lines
|
||||
are its imports, `class` statements and `Field(...)` declarations, and the missing
|
||||
ranges are its `from_api_response()` bodies.
|
||||
|
||||
Judge a module against that import-only floor, not against zero, and do not set a
|
||||
Codecov target from the raw total.
|
||||
</Warning>
|
||||
|
||||
### Shared Fixtures
|
||||
|
||||
All fixtures live in `mcp_server/tests/conftest.py`. Three are autouse and apply to
|
||||
every test: the environment is pinned to deterministic values, real socket
|
||||
connections are blocked, and the API client singleton registry is snapshotted and
|
||||
restored.
|
||||
|
||||
| Fixture | What it gives you |
|
||||
|---------|-------------------|
|
||||
| `mock_api_client` | The API client singleton with its transport mocked. The workhorse. |
|
||||
| `mock_router` | Route registry and request recorder |
|
||||
| `mcp_root_server` | The mounted root server, for in-memory client tests |
|
||||
| `health_client` | Starlette `TestClient` for the `/health` route |
|
||||
| `http_request_headers` | Injects request headers for HTTP-transport auth tests |
|
||||
| `hub_router` / `docs_router` | Mock the Hub and Docs sub-servers' sync HTTP clients |
|
||||
| `api_client` / `isolated_api_client` | The live singleton / a freshly-constructed one |
|
||||
|
||||
Helpers live in `mcp_server/tests/helpers/`: JSON:API document builders
|
||||
(`jsonapi.py`), the `MockRouter` (`http.py`), tool-contract assertions
|
||||
(`assertions.py`) and fake credentials (`tokens.py`).
|
||||
|
||||
### Writing a Tool Test
|
||||
|
||||
Drive tools through an in-memory MCP client, and open the client inside the test —
|
||||
FastMCP warns that holding a client in a fixture causes event-loop problems.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
|
||||
|
||||
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": {"checkid": "s3_bucket_public_access"},
|
||||
}
|
||||
|
||||
|
||||
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 the cheaper `/findings/latest`."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings/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() == ["GET /api/v1/findings/latest"]
|
||||
```
|
||||
|
||||
The exemplar suite covers `findings` end to end — `tests/prowler_app/models/test_findings.py`
|
||||
and `tests/prowler_app/tools/test_findings.py`. It is deliberately one feature
|
||||
across both layers rather than a scattering of unrelated samples, and `findings`
|
||||
is the feature that exercises the whole foundation: two-tier models, nested
|
||||
sub-models, both relationship shapes, endpoint switching on a date range,
|
||||
list-to-CSV filter encoding, and a tool that returns prose instead of a model.
|
||||
|
||||
Note the two files share a name. That is why `__init__.py` is required in every
|
||||
`tests/` subdirectory here — without it they would collide on import.
|
||||
|
||||
<Warning>
|
||||
Tool parameters are declared with pydantic `Field(default=...)`, and only FastMCP's
|
||||
tool wrapper resolves those defaults. Calling a tool method directly with an
|
||||
argument omitted leaves it as a raw `FieldInfo` object, which is truthy — so a
|
||||
filter such as `if email:` silently builds a query out of the `FieldInfo` repr.
|
||||
Call tools through the client, or pass every argument explicitly.
|
||||
</Warning>
|
||||
|
||||
### Why the API Key Is Pinned, Not Stripped
|
||||
|
||||
`prowler_app/server.py` builds every tool at import time. Constructing a tool
|
||||
reaches `ProwlerAppAuth`, which raises when `PROWLER_API_KEY` is missing, and
|
||||
`load_all_tools` swallows that error per tool class. The result is that the whole
|
||||
`prowler_*` namespace registers **zero** tools while the server still logs
|
||||
"Successfully mounted Prowler tools server".
|
||||
|
||||
The suite therefore pins a fake key in `[tool.pytest_env]`, which is applied before
|
||||
any test module is imported, and `tests/test_server.py` asserts each namespace is
|
||||
non-empty so this failure can never return silently.
|
||||
|
||||
<Note>
|
||||
`ProwlerAppAuth` resolves `PROWLER_MCP_TRANSPORT_MODE` and `API_BASE_URL` in its
|
||||
default arguments, which Python evaluates once at module import. `monkeypatch.setenv`
|
||||
cannot change them — pass `mode=` and `base_url=` explicitly in auth tests.
|
||||
</Note>
|
||||
|
||||
For the full set of rules and templates, see the
|
||||
[`prowler-test-mcp` skill](https://github.com/prowler-cloud/prowler/blob/master/skills/prowler-test-mcp/SKILL.md)
|
||||
and the [official FastMCP testing guide](https://gofastmcp.com/development/tests).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
+25
-6
@@ -15,6 +15,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Writing tests for the MCP server | `prowler-test-mcp` |
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -48,9 +49,9 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
|
||||
### Three Sub-Servers
|
||||
|
||||
```python
|
||||
await prowler_mcp_server.import_server(hub_mcp_server, prefix="prowler_hub")
|
||||
await prowler_mcp_server.import_server(app_mcp_server, prefix="prowler_app")
|
||||
await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
|
||||
prowler_mcp_server.mount(hub_mcp_server, namespace="prowler_hub")
|
||||
prowler_mcp_server.mount(app_mcp_server, namespace="prowler")
|
||||
prowler_mcp_server.mount(docs_mcp_server, namespace="prowler_docs")
|
||||
```
|
||||
|
||||
### Tool Naming
|
||||
@@ -62,7 +63,7 @@ await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
|
||||
|
||||
## TECH STACK
|
||||
|
||||
Python 3.12+ | FastMCP 2.13.1 | httpx (async) | Pydantic | uv
|
||||
Python 3.12+ | FastMCP 3.4.4 | httpx (async) | Pydantic | uv | pytest
|
||||
|
||||
---
|
||||
|
||||
@@ -85,9 +86,23 @@ mcp_server/prowler_mcp_server/
|
||||
|
||||
## COMMANDS
|
||||
|
||||
From `mcp_server/`:
|
||||
|
||||
```bash
|
||||
cd mcp_server && uv run prowler-mcp # STDIO mode
|
||||
cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
cd mcp_server
|
||||
|
||||
uv run prowler-mcp # STDIO mode
|
||||
uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
|
||||
uv run pytest # Run the test suite
|
||||
uv run pytest tests/prowler_app/models # Run one area
|
||||
uv run pytest --cov=./prowler_mcp_server # With coverage
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make test-mcp # Run the MCP test suite exactly as CI does
|
||||
```
|
||||
|
||||
---
|
||||
@@ -100,3 +115,7 @@ cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Error handling returns structured responses
|
||||
- [ ] Parameter descriptions use Pydantic `Field()`
|
||||
- [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the
|
||||
package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`),
|
||||
as the SDK does for `prowler/` -> `tests/`
|
||||
- [ ] `uv run pytest` passes
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Test foundation for the MCP server with shared fixtures, JSON:API builders, mocked HTTP transports and CI coverage reporting
|
||||
@@ -5,7 +5,11 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"bandit==1.8.3",
|
||||
"coverage==7.15.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-env==1.1.5",
|
||||
"ruff==0.15.11",
|
||||
"vulture==2.14"
|
||||
]
|
||||
@@ -27,8 +31,32 @@ prowler-mcp = "prowler_mcp_server.main:main"
|
||||
[tool.pytest]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config"
|
||||
# `asyncio_mode = "auto"` lets `async def test_*` run without a per-test marker;
|
||||
# the server is async end to end, so requiring one would be pure noise. Setting
|
||||
# the fixture loop scope explicitly silences a pytest-asyncio deprecation warning.
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = [
|
||||
"error",
|
||||
# Starlette's TestClient warns that it will require httpx2. The httpx pin is a
|
||||
# deliberate project-wide choice, so this stays allowed until that pin moves.
|
||||
"default::starlette.exceptions.StarletteDeprecationWarning"
|
||||
]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
# Applied before any conftest or test module is imported, which is what makes it
|
||||
# work: `prowler_app/server.py` builds every tool at import time, and a tool whose
|
||||
# construction raises (as it does without an API key) is swallowed by
|
||||
# `load_all_tools`, leaving the `prowler_*` namespace silently empty. Pinning a
|
||||
# fake key here keeps the full tool surface loadable and stops a developer's
|
||||
# `mcp_server/.env` from reaching the suite.
|
||||
[tool.pytest_env]
|
||||
API_BASE_URL = "https://api.testing.invalid/api/v1"
|
||||
PROWLER_API_KEY = "pk_fake_api_key_for_unit_testing_only"
|
||||
PROWLER_MCP_TRANSPORT_MODE = "stdio"
|
||||
|
||||
# Shared ruff baseline (kept in sync with api/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
[tool.ruff]
|
||||
|
||||
@@ -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)
|
||||
Generated
+115
@@ -213,6 +213,75 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.1"
|
||||
@@ -730,7 +799,11 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "bandit" },
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-env" },
|
||||
{ name = "ruff" },
|
||||
{ name = "vulture" },
|
||||
]
|
||||
@@ -744,7 +817,11 @@ requires-dist = [
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "bandit", specifier = "==1.8.3" },
|
||||
{ name = "coverage", specifier = "==7.15.2" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.4.0" },
|
||||
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||
{ name = "pytest-env", specifier = "==1.1.5" },
|
||||
{ name = "ruff", specifier = "==0.15.11" },
|
||||
{ name = "vulture", specifier = "==2.14" },
|
||||
]
|
||||
@@ -940,6 +1017,44 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-env"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
|
||||
@@ -72,10 +72,12 @@ Use `@mcp.tool()` decorator directly—no BaseTool or models required.
|
||||
- [ ] Error handling returns `{"error": str, "status": "failed"}`
|
||||
- [ ] Parameters use `Field()` with descriptions
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Tests added under `mcp_server/tests/`
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../../docs/developer-guide/mcp-server.mdx)
|
||||
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../docs/developer-guide/mcp-server.mdx)
|
||||
- **Templates**: See [assets/](assets/) for tool and model templates
|
||||
- **Testing**: See [prowler-test-mcp](../prowler-test-mcp/SKILL.md) for fixtures and test patterns
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: prowler-test-mcp
|
||||
description: >
|
||||
Testing patterns for the Prowler MCP Server: in-memory FastMCP clients, the
|
||||
ProwlerAPIClient singleton, JSON:API model builders and mocked httpx transports.
|
||||
Trigger: When writing tests under mcp_server/tests/ (tools, models, api_client, auth, sub-servers).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0.0"
|
||||
scope: [root, mcp_server]
|
||||
auto_invoke:
|
||||
- "Writing Prowler MCP server tests"
|
||||
- "Testing MCP tools or models"
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS drive tools through an in-memory client: `async with Client(mcp_root_server)`.
|
||||
Tool parameters use pydantic `Field(default=...)`, and only FastMCP's wrapper
|
||||
resolves those defaults. Calling a tool method directly with an argument omitted
|
||||
leaves it as a raw `FieldInfo` — which is truthy, so `if email:` silently builds
|
||||
a filter out of the `FieldInfo` repr. Direct calls MUST pass every argument.
|
||||
- NEVER open a `fastmcp.Client` inside a fixture. FastMCP warns this causes
|
||||
hard-to-diagnose event-loop issues; open it inline in the test.
|
||||
- ALWAYS use the `mock_api_client` fixture; NEVER construct a `ProwlerAPIClient`.
|
||||
Tool instances captured the singleton by reference at import time, so only an
|
||||
in-place patch of `.client` reaches them.
|
||||
- NEVER clear `SingletonMeta._instances`. It orphans every registered tool on an
|
||||
instance holding a real `httpx.AsyncClient`. Use `isolated_api_client` if you
|
||||
genuinely need a fresh instance.
|
||||
- NEVER strip `PROWLER_API_KEY`. Tools are built at import time and a construction
|
||||
failure is swallowed, so the whole `prowler_*` namespace silently drops to zero
|
||||
tools. It is pinned in `[tool.pytest_env]`.
|
||||
- For `ProwlerAppAuth`, pass `mode=` / `base_url=` explicitly. Those are resolved in
|
||||
default arguments, evaluated once at module import, so `monkeypatch.setenv` has
|
||||
no effect on them.
|
||||
- NEVER assert an exact tool count — every future branch would have to bump it.
|
||||
- Assert on `result.data` (structured output), not `result.content[0].text`.
|
||||
- Tests are `test_*.py` (prefix), like the API — not the SDK's `*_test.py` suffix.
|
||||
- `__init__.py` IS required in every `tests/` subdirectory here (unlike the SDK's
|
||||
repo-root `tests/`), or same-named modules collide under pytest's import mode.
|
||||
- Async tests need no marker (`asyncio_mode = "auto"`). Do not use `@pytest.mark.anyio`.
|
||||
- Use only obviously-fake credentials from `tests.helpers.tokens` (TruffleHog).
|
||||
- One behaviour per test; keep tests self-contained and order-independent.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout
|
||||
|
||||
Mirror the source tree *below the package root* — drop the `prowler_mcp_server/`
|
||||
level, exactly as the SDK maps `prowler/providers/...` to `tests/providers/...`.
|
||||
So `prowler_mcp_server/prowler_app/tools/` is tested in `tests/prowler_app/tools/`.
|
||||
|
||||
```text
|
||||
mcp_server/tests/
|
||||
├── conftest.py # all shared fixtures
|
||||
├── helpers/ # jsonapi.py, http.py, assertions.py, tokens.py
|
||||
├── test_server.py # mounted-server contract
|
||||
├── test_health.py
|
||||
├── prowler_app/{models,tools,utils}/
|
||||
├── prowler_hub/
|
||||
└── prowler_documentation/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Fixtures
|
||||
|
||||
| Fixture | Autouse | What it gives you |
|
||||
|---------|---------|-------------------|
|
||||
| `_pinned_environment` | yes | Deterministic env; blocks a developer's `.env` from leaking |
|
||||
| `_no_real_network` | yes | Any real socket connect raises `RuntimeError` |
|
||||
| `_singleton_registry_guard` | yes | Snapshots/restores `SingletonMeta._instances` |
|
||||
| `mock_router` | no | Route registry + request recorder |
|
||||
| `api_client` | no | The live `ProwlerAPIClient` singleton |
|
||||
| `mock_api_client` | no | **The workhorse** — singleton with a mocked transport |
|
||||
| `isolated_api_client` | no | Evicts the singleton, for construction/identity tests |
|
||||
| `mcp_root_server` | no | The mounted root server (session-scoped) |
|
||||
| `health_client` | no | Starlette `TestClient` for `/health` |
|
||||
| `http_request_headers` | no | Injects headers for HTTP-mode auth |
|
||||
| `hub_router` | no | Mocks the Hub sub-server's two sync clients |
|
||||
| `docs_router` | no | Mocks the docs search engine's two sync clients |
|
||||
|
||||
### `MockRouter`
|
||||
|
||||
```python
|
||||
mock_router.add("GET", "/api/v1/users", json=jsonapi_collection([...]))
|
||||
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "completed"))
|
||||
|
||||
mock_router.request_for("GET", "/api/v1/users") # last request, for header asserts
|
||||
mock_router.query_params("GET", "/api/v1/users") # decoded query string
|
||||
mock_router.paths() # everything requested so far
|
||||
```
|
||||
|
||||
Register a route more than once to return a sequence — the last response repeats.
|
||||
That is how you drive `poll_task_until_complete` (`executing`, `executing`,
|
||||
`completed`). An unregistered request raises, listing what *was* registered.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patterns
|
||||
|
||||
**Tool test** — see `assets/mcp_tool_test.py`. Register routes, call through the
|
||||
in-memory client, assert on `result.data` *and* on the recorded request. When a
|
||||
tool chooses between endpoints, assert `mock_router.paths()` — a wrong choice is
|
||||
invisible in the response body.
|
||||
|
||||
**Model test** — see `assets/mcp_model_test.py`. Build the document with the
|
||||
`jsonapi` helpers, run `from_api_response()`, assert on both the model and
|
||||
`model_dump()`. `MinimalSerializerMixin` makes those differ, and an absent
|
||||
relationship (`None`) must never be conflated with an empty one (`[]`).
|
||||
|
||||
**Contract test** — see `assets/mcp_contract_test.py`. Namespacing and
|
||||
description coverage across every registered tool.
|
||||
|
||||
The worked example in the repo is `findings`, covered across both layers in
|
||||
`tests/prowler_app/{models,tools}/test_findings.py`. Read those first — they
|
||||
exercise every foundation capability in one feature.
|
||||
|
||||
### Reading coverage
|
||||
|
||||
Coverage has a meaningless high floor. Model modules are almost entirely class-body
|
||||
`Field(...)` declarations that execute at import, and `prowler_app/server.py` imports
|
||||
every model module at import time. **Importing the package with zero tests already
|
||||
reports 36% overall**, and individual model modules 54–84%.
|
||||
|
||||
So a model module at ~68% with no tests has none of its logic covered — the missing
|
||||
ranges are the `from_api_response()` bodies, which is the only part worth testing.
|
||||
Compare against the import-only floor, never against zero, and do not set a Codecov
|
||||
target from the raw total.
|
||||
|
||||
### Where fixture data lives
|
||||
|
||||
`tests/helpers/` is feature-agnostic and must stay that way: it holds the JSON:API
|
||||
*shape*, not any feature's data. Per-feature attribute dictionaries
|
||||
(`FINDING_ATTRIBUTES`, `CHECK_METADATA`, …) belong as module-level constants in
|
||||
the test module that uses them. Do not add feature fixtures to `helpers/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Commands
|
||||
|
||||
From `mcp_server/`:
|
||||
|
||||
```bash
|
||||
cd mcp_server
|
||||
|
||||
uv run pytest # whole suite
|
||||
uv run pytest tests/prowler_app/models # one area
|
||||
uv run pytest --cov=./prowler_mcp_server # with coverage
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make test-mcp # runs the MCP suite exactly as CI does
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference
|
||||
|
||||
- Fixtures and the reasoning behind them: `mcp_server/tests/conftest.py`
|
||||
- Testing section of `docs/developer-guide/mcp-server.mdx`
|
||||
- Official FastMCP testing guide: <https://gofastmcp.com/development/tests>
|
||||
@@ -0,0 +1,49 @@
|
||||
# Example: Prowler MCP Server contract test patterns
|
||||
# Source: mcp_server/tests/test_server.py
|
||||
|
||||
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):
|
||||
"""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". Assert each namespace is non-empty
|
||||
-- never assert an exact count, which every future branch would have to bump.
|
||||
"""
|
||||
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.
|
||||
|
||||
A tool or parameter with no description is registered but effectively
|
||||
invisible, so this is a correctness check rather than a style one.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
assert_tool_contract(tool)
|
||||
@@ -0,0 +1,116 @@
|
||||
# Example: Prowler MCP Server model test patterns
|
||||
# Source: mcp_server/tests/prowler_app/models/test_findings.py
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.findings import (
|
||||
DetailedFinding,
|
||||
FindingsListResponse,
|
||||
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": [],
|
||||
"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,
|
||||
}
|
||||
|
||||
DETAILED_ATTRIBUTES = {
|
||||
**FINDING_ATTRIBUTES,
|
||||
"inserted_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def test_nested_attributes_are_flattened_onto_the_model():
|
||||
"""Assert on the fields the model derives, not the ones it copies verbatim."""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.check_id == "s3_bucket_public_access"
|
||||
|
||||
|
||||
def test_empty_fields_are_dropped_from_the_serialized_payload():
|
||||
"""Assert on `model_dump()` too -- MinimalSerializerMixin drops empty values.
|
||||
|
||||
A model may override that for fields whose empty form carries meaning, and
|
||||
that override is exactly the kind of thing a refactor breaks silently.
|
||||
"""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert "muted_reason" not in finding.model_dump()
|
||||
|
||||
|
||||
def test_both_relationship_shapes_are_parsed():
|
||||
"""To-one reduces to a single id, to-many to a list of ids."""
|
||||
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_missing_relationships_are_tolerated():
|
||||
"""Omit `relationships=` entirely to express absence.
|
||||
|
||||
Pass an empty `jsonapi_relationship_many(...)` instead to express "present and
|
||||
empty" -- some models must distinguish the two.
|
||||
"""
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.scan_id is None
|
||||
assert finding.resource_ids == []
|
||||
|
||||
|
||||
def test_list_response_carries_pagination_metadata():
|
||||
"""`jsonapi_collection` emits meta.pagination exactly as *ListResponse reads it."""
|
||||
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, result.total_num_pages, result.total_num_finding) == (
|
||||
2,
|
||||
7,
|
||||
312,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Example: Prowler MCP Server tool test patterns
|
||||
# Source: mcp_server/tests/prowler_app/tools/test_findings.py
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
|
||||
|
||||
LATEST = "/api/v1/findings/latest"
|
||||
HISTORICAL = "/api/v1/findings"
|
||||
|
||||
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": {"checkid": "s3_bucket_public_access"},
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_returns_a_simplified_payload(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Drive the tool through the protocol; assert on the structured result.
|
||||
|
||||
This is the default pattern. Going through the in-memory client is what
|
||||
resolves the pydantic `Field(default=...)` declarations on the tool's
|
||||
parameters -- calling the method directly leaves omitted arguments as raw
|
||||
`FieldInfo` objects, which are truthy and build nonsense filters.
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
async def test_tool_arguments_become_api_query_parameters(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Assert on the recorded request, not only the returned payload.
|
||||
|
||||
The request is where filter translation, pagination and field selection live,
|
||||
and it is what breaks silently when an API contract shifts.
|
||||
"""
|
||||
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"]}
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", LATEST)
|
||||
assert params["filter[severity__in]"] == "critical,high" # lists become CSV
|
||||
assert params["filter[status__in]"] == "FAIL" # the tool's default
|
||||
|
||||
|
||||
async def test_tool_picks_the_right_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Assert which endpoint was called when the tool chooses between several.
|
||||
|
||||
A wrong choice here is a performance regression the response body alone would
|
||||
never reveal, so `paths()` is the assertion that catches it.
|
||||
"""
|
||||
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"}
|
||||
)
|
||||
|
||||
assert mock_router.paths() == [f"GET {HISTORICAL}"]
|
||||
|
||||
|
||||
async def test_tool_validates_input_before_calling_the_api(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Local validation must reject before any request goes out."""
|
||||
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_tool_surfaces_the_api_error_detail(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Error text reaches the model, so assert on it rather than on the type alone."""
|
||||
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_polling_tool_waits_for_a_terminal_task_state(
|
||||
mock_api_client, mock_router
|
||||
):
|
||||
"""Register a route repeatedly to return a sequence; the last entry repeats."""
|
||||
from tests.helpers.jsonapi import task_document
|
||||
|
||||
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "executing"))
|
||||
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "completed"))
|
||||
|
||||
result = await mock_api_client.poll_task_until_complete("t1", poll_interval=0)
|
||||
|
||||
assert result["data"]["attributes"]["state"] == "completed"
|
||||
Reference in New Issue
Block a user