diff --git a/.github/workflows/mcp-tests.yml b/.github/workflows/mcp-tests.yml new file mode 100644 index 0000000000..4492ded14c --- /dev/null +++ b/.github/workflows/mcp-tests.yml @@ -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 diff --git a/Makefile b/Makefile index b05a7dc2db..0f5a6e7557 100644 --- a/Makefile +++ b/Makefile @@ -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 && \ diff --git a/codecov.yml b/codecov.yml index d7e9a1c312..21398b70f7 100644 --- a/codecov.yml +++ b/codecov.yml @@ -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" diff --git a/docs/developer-guide/mcp-server.mdx b/docs/developer-guide/mcp-server.mdx index ca3c72da21..186786651f 100644 --- a/docs/developer-guide/mcp-server.mdx +++ b/docs/developer-guide/mcp-server.mdx @@ -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 + + +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. + + +### 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. + + +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. + + +### 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. + + +`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. + + +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 diff --git a/mcp_server/AGENTS.md b/mcp_server/AGENTS.md index 1d3d808e6a..e786f9a5b7 100644 --- a/mcp_server/AGENTS.md +++ b/mcp_server/AGENTS.md @@ -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 diff --git a/mcp_server/changelog.d/mcp-test-foundation.added.md b/mcp_server/changelog.d/mcp-test-foundation.added.md new file mode 100644 index 0000000000..914c9fce24 --- /dev/null +++ b/mcp_server/changelog.d/mcp-test-foundation.added.md @@ -0,0 +1 @@ +Test foundation for the MCP server with shared fixtures, JSON:API builders, mocked HTTP transports and CI coverage reporting diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 68b73ff8d7..e87d9bc50c 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -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] diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py new file mode 100644 index 0000000000..6e1fb672ba --- /dev/null +++ b/mcp_server/tests/conftest.py @@ -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 diff --git a/mcp_server/tests/helpers/__init__.py b/mcp_server/tests/helpers/__init__.py new file mode 100644 index 0000000000..0fa7fb48bb --- /dev/null +++ b/mcp_server/tests/helpers/__init__.py @@ -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", +] diff --git a/mcp_server/tests/helpers/assertions.py b/mcp_server/tests/helpers/assertions.py new file mode 100644 index 0000000000..06445cbaa9 --- /dev/null +++ b/mcp_server/tests/helpers/assertions.py @@ -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) + ] diff --git a/mcp_server/tests/helpers/http.py b/mcp_server/tests/helpers/http.py new file mode 100644 index 0000000000..2315c60ab3 --- /dev/null +++ b/mcp_server/tests/helpers/http.py @@ -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] diff --git a/mcp_server/tests/helpers/jsonapi.py b/mcp_server/tests/helpers/jsonapi.py new file mode 100644 index 0000000000..ac35d46e46 --- /dev/null +++ b/mcp_server/tests/helpers/jsonapi.py @@ -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)) diff --git a/mcp_server/tests/helpers/tokens.py b/mcp_server/tests/helpers/tokens.py new file mode 100644 index 0000000000..93af70ad91 --- /dev/null +++ b/mcp_server/tests/helpers/tokens.py @@ -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" diff --git a/mcp_server/tests/prowler_app/__init__.py b/mcp_server/tests/prowler_app/__init__.py new file mode 100644 index 0000000000..255373895e --- /dev/null +++ b/mcp_server/tests/prowler_app/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler App sub-server.""" diff --git a/mcp_server/tests/prowler_app/models/__init__.py b/mcp_server/tests/prowler_app/models/__init__.py new file mode 100644 index 0000000000..586e285982 --- /dev/null +++ b/mcp_server/tests/prowler_app/models/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler App Pydantic models.""" diff --git a/mcp_server/tests/prowler_app/models/test_findings.py b/mcp_server/tests/prowler_app/models/test_findings.py new file mode 100644 index 0000000000..3556201012 --- /dev/null +++ b/mcp_server/tests/prowler_app/models/test_findings.py @@ -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 diff --git a/mcp_server/tests/prowler_app/models/test_utils.py b/mcp_server/tests/prowler_app/models/test_utils.py new file mode 100644 index 0000000000..b51524a9ea --- /dev/null +++ b/mcp_server/tests/prowler_app/models/test_utils.py @@ -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"] diff --git a/mcp_server/tests/prowler_app/tools/__init__.py b/mcp_server/tests/prowler_app/tools/__init__.py new file mode 100644 index 0000000000..2a8a7db94c --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler App MCP tools.""" diff --git a/mcp_server/tests/prowler_app/tools/test_findings.py b/mcp_server/tests/prowler_app/tools/test_findings.py new file mode 100644 index 0000000000..b1e1a3aeb3 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_findings.py @@ -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 diff --git a/mcp_server/tests/prowler_app/utils/__init__.py b/mcp_server/tests/prowler_app/utils/__init__.py new file mode 100644 index 0000000000..9c63f3cf09 --- /dev/null +++ b/mcp_server/tests/prowler_app/utils/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler App shared utilities.""" diff --git a/mcp_server/tests/prowler_app/utils/test_api_client.py b/mcp_server/tests/prowler_app/utils/test_api_client.py new file mode 100644 index 0000000000..2dc0417cc6 --- /dev/null +++ b/mcp_server/tests/prowler_app/utils/test_api_client.py @@ -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() diff --git a/mcp_server/tests/prowler_app/utils/test_auth.py b/mcp_server/tests/prowler_app/utils/test_auth.py new file mode 100644 index 0000000000..d39e5826d7 --- /dev/null +++ b/mcp_server/tests/prowler_app/utils/test_auth.py @@ -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}" diff --git a/mcp_server/tests/prowler_documentation/__init__.py b/mcp_server/tests/prowler_documentation/__init__.py new file mode 100644 index 0000000000..982ef891e6 --- /dev/null +++ b/mcp_server/tests/prowler_documentation/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler Documentation sub-server.""" diff --git a/mcp_server/tests/prowler_hub/__init__.py b/mcp_server/tests/prowler_hub/__init__.py new file mode 100644 index 0000000000..9c5c9f6cd2 --- /dev/null +++ b/mcp_server/tests/prowler_hub/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowler Hub sub-server.""" diff --git a/mcp_server/tests/test_health.py b/mcp_server/tests/test_health.py index 47a676960d..f52b9a93e0 100644 --- a/mcp_server/tests/test_health.py +++ b/mcp_server/tests/test_health.py @@ -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 diff --git a/mcp_server/tests/test_server.py b/mcp_server/tests/test_server.py new file mode 100644 index 0000000000..30ab1fc92c --- /dev/null +++ b/mcp_server/tests/test_server.py @@ -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) diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index 2d76e9968d..40ad59212b 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -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" diff --git a/skills/prowler-mcp/SKILL.md b/skills/prowler-mcp/SKILL.md index 704acf8553..c98f6a886f 100644 --- a/skills/prowler-mcp/SKILL.md +++ b/skills/prowler-mcp/SKILL.md @@ -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 diff --git a/skills/prowler-test-mcp/SKILL.md b/skills/prowler-test-mcp/SKILL.md new file mode 100644 index 0000000000..e9832894ea --- /dev/null +++ b/skills/prowler-test-mcp/SKILL.md @@ -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: diff --git a/skills/prowler-test-mcp/assets/mcp_contract_test.py b/skills/prowler-test-mcp/assets/mcp_contract_test.py new file mode 100644 index 0000000000..830609cc2f --- /dev/null +++ b/skills/prowler-test-mcp/assets/mcp_contract_test.py @@ -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) diff --git a/skills/prowler-test-mcp/assets/mcp_model_test.py b/skills/prowler-test-mcp/assets/mcp_model_test.py new file mode 100644 index 0000000000..f73d4e6e4d --- /dev/null +++ b/skills/prowler-test-mcp/assets/mcp_model_test.py @@ -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, + ) diff --git a/skills/prowler-test-mcp/assets/mcp_tool_test.py b/skills/prowler-test-mcp/assets/mcp_tool_test.py new file mode 100644 index 0000000000..4dd754b8f0 --- /dev/null +++ b/skills/prowler-test-mcp/assets/mcp_tool_test.py @@ -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"