mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""Tests for the provider tools.
|
|
|
|
Two behaviours drive most of the assertions here, and both are about telling a
|
|
failure apart from an outcome that is merely not final yet:
|
|
|
|
* Deleting a provider removes it together with its scans, findings and
|
|
resources, in a background task that routinely outlives the polling window.
|
|
A deletion still running is not a failure, and reporting it as one invites a
|
|
retry of a destructive call that is already in flight.
|
|
* ``connect_provider`` runs a connection check, and a check that could not be
|
|
run says nothing about the provider's credentials. Reporting it as ``failed``
|
|
blames an AWS role for an expired Prowler credential and sends the user off to
|
|
fix something that works.
|
|
|
|
Tools are driven through an in-memory MCP client so FastMCP resolves the
|
|
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
|
|
it.
|
|
"""
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
|
|
from tests.helpers.http import MockRouter
|
|
from tests.helpers.jsonapi import (
|
|
jsonapi_collection,
|
|
jsonapi_document,
|
|
jsonapi_error,
|
|
jsonapi_resource,
|
|
task_document,
|
|
)
|
|
|
|
PROVIDERS = "/api/v1/providers"
|
|
PROVIDER = f"{PROVIDERS}/p1"
|
|
CONNECTION = f"{PROVIDER}/connection"
|
|
SECRETS = f"{PROVIDERS}/secrets"
|
|
TASK = "/api/v1/tasks/t1"
|
|
|
|
PROVIDER_ATTRIBUTES = {
|
|
"uid": "123456789012",
|
|
"provider": "aws",
|
|
"alias": "production",
|
|
"connection": {"connected": True},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_fast_polling(monkeypatch, api_client):
|
|
"""Run the real polling loop, with a timeout a test can afford to wait out.
|
|
|
|
The timeout path is the one worth testing here -- it is what used to be
|
|
reported as a failed deletion -- so the loop, its exception and the fallback
|
|
that reads the task afterwards all stay real. Only the 60 seconds go.
|
|
"""
|
|
original = type(api_client).poll_task_until_complete
|
|
|
|
async def _fast(self, task_id, **_overridden):
|
|
return await original(self, task_id, timeout=0.3, poll_interval=0.05)
|
|
|
|
monkeypatch.setattr(type(api_client), "poll_task_until_complete", _fast)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_polling_timeout(monkeypatch, api_client):
|
|
"""Make the polling window run out on the first call, without the wait.
|
|
|
|
The clock is what ends the polling loop here, so a test about *what happens
|
|
afterwards* has no reason to spend it. The read the fallback then makes is
|
|
the real one.
|
|
"""
|
|
|
|
async def _timeout(self, task_id, **_overridden):
|
|
raise TimeoutError(f"Task {task_id} polling timed out after 60 seconds.")
|
|
|
|
monkeypatch.setattr(type(api_client), "poll_task_until_complete", _timeout)
|
|
|
|
|
|
def stub_deletion_start(mock_router: MockRouter) -> MockRouter:
|
|
"""Serve the DELETE as Prowler does: a task to poll, not a finished deletion."""
|
|
return mock_router.add(
|
|
"DELETE", PROVIDER, json=jsonapi_document(jsonapi_resource("tasks", "t1", {}))
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------- deletion
|
|
|
|
|
|
async def test_a_refused_deletion_is_an_error_not_a_result(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""Nothing started, so the classifier owns the message.
|
|
|
|
Returned as ``{"deleted": false}`` it arrives with ``isError: false`` and a
|
|
model has no reason to treat it as anything but a completed call.
|
|
"""
|
|
mock_router.add(
|
|
"DELETE", PROVIDER, status=403, json=jsonapi_error(403, "Not allowed.")
|
|
)
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
with pytest.raises(Exception, match="not allowed to do this"):
|
|
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
|
|
|
|
|
async def test_a_deletion_with_no_task_back_warns_before_a_blind_retry(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""Prowler accepted it, so the deletion is probably running.
|
|
|
|
Without the task ID there is nothing to watch it with, which makes "check
|
|
whether it is gone" the only safe instruction.
|
|
"""
|
|
mock_router.add("DELETE", PROVIDER, json={"data": {}})
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
with pytest.raises(Exception, match="did not return the ID"):
|
|
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
|
|
|
assert mock_router.paths() == [f"DELETE {PROVIDER}"]
|
|
|
|
|
|
async def test_a_finished_deletion_reports_it_plainly(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""The success path is unchanged: the provider is gone."""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add("GET", TASK, json=task_document("t1", "completed"))
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
result = await client.call_tool(
|
|
"prowler_delete_provider", {"provider_id": "p1"}
|
|
)
|
|
|
|
assert result.data["status"] == "deleted"
|
|
|
|
|
|
async def test_a_deletion_still_running_is_not_reported_as_a_failure(
|
|
mcp_root_server, mock_api_client, mock_router, mock_fast_polling
|
|
):
|
|
"""Outliving the polling window is normal for a provider with many findings.
|
|
|
|
The task ID goes back so the deletion can be followed, and the message says
|
|
not to send it again -- which is the whole point of not calling this failed.
|
|
"""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add("GET", TASK, json=task_document("t1", "executing"))
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
result = await client.call_tool(
|
|
"prowler_delete_provider", {"provider_id": "p1"}
|
|
)
|
|
|
|
assert result.data["status"] == "in_progress"
|
|
assert result.data["task_id"] == "t1"
|
|
assert "Do not send the deletion again" in result.data["message"]
|
|
|
|
|
|
async def test_a_deletion_that_finished_just_after_the_wait_is_reported_as_deleted(
|
|
mcp_root_server, mock_api_client, mock_router, mock_polling_timeout
|
|
):
|
|
"""Polling gives up on the clock, not on the task.
|
|
|
|
A deletion that completed a moment after the last poll is a finished
|
|
deletion, and the read the fallback makes is what says so. Reporting it as
|
|
still running would send the caller off to watch a provider that is gone.
|
|
"""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add("GET", TASK, json=task_document("t1", "completed"))
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
result = await client.call_tool(
|
|
"prowler_delete_provider", {"provider_id": "p1"}
|
|
)
|
|
|
|
assert result.data["status"] == "deleted"
|
|
assert "task_id" not in result.data
|
|
|
|
|
|
async def test_a_deletion_task_that_stopped_is_an_error_naming_what_is_left(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""Here the provider really is still there, so this one is a failure.
|
|
|
|
A provider is removed together with everything attached to it, so a task
|
|
that stopped halfway can leave part of that gone -- which is why the message
|
|
sends the caller to look rather than asserting the state.
|
|
"""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add("GET", TASK, json=task_document("t1", "failed", error="boom"))
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
with pytest.raises(Exception, match="ended as 'failed'"):
|
|
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
|
|
|
|
|
async def test_a_deletion_task_failure_does_not_relay_the_upstream_text(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""The task's own error is a celery traceback; it stays in the log."""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add(
|
|
"GET",
|
|
TASK,
|
|
json=task_document("t1", "failed", error="Traceback: secret-internal-detail"),
|
|
)
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
with pytest.raises(Exception) as raised:
|
|
await client.call_tool("prowler_delete_provider", {"provider_id": "p1"})
|
|
|
|
assert "secret-internal-detail" not in str(raised.value)
|
|
|
|
|
|
async def test_a_deletion_whose_progress_cannot_be_read_still_says_do_not_retry(
|
|
mcp_root_server, mock_api_client, mock_router, mock_fast_polling
|
|
):
|
|
"""The outcome is unknown, which for a destructive call means: do not repeat.
|
|
|
|
Reading the task is what tells "still running" from "stopped", so when that
|
|
read fails too the message drops the claim rather than guessing at one.
|
|
"""
|
|
stub_deletion_start(mock_router)
|
|
mock_router.add("GET", TASK, status=503, json=jsonapi_error(503, "Unavailable."))
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
result = await client.call_tool(
|
|
"prowler_delete_provider", {"provider_id": "p1"}
|
|
)
|
|
|
|
assert result.data["status"] == "in_progress"
|
|
assert "could not be read" in result.data["message"]
|
|
assert "Do not send the deletion again" in result.data["message"]
|
|
# The failure that got us here is the classifier's to phrase, so its raw
|
|
# text stays in the log rather than riding along in the message.
|
|
assert "API request failed" not in result.data["message"]
|
|
|
|
|
|
# ------------------------------------------------------- connection check
|
|
|
|
|
|
async def test_a_connection_check_that_cannot_run_is_not_reported_as_failed(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""`not_tested` says nothing about the credentials, and that is the point.
|
|
|
|
A 401 here is this server's own credential, not the provider's. Calling it
|
|
`failed` tells the user their AWS role is broken when it is fine.
|
|
"""
|
|
# Registered in order and consumed in order: the lookup before the create
|
|
# finds nothing, the one after it finds the provider that was just made.
|
|
mock_router.add("GET", PROVIDERS, json=jsonapi_collection([]))
|
|
mock_router.add(
|
|
"GET",
|
|
PROVIDERS,
|
|
json=jsonapi_collection(
|
|
[jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)]
|
|
),
|
|
)
|
|
mock_router.add(
|
|
"POST",
|
|
PROVIDERS,
|
|
json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)),
|
|
)
|
|
mock_router.add(
|
|
"POST", CONNECTION, status=401, json=jsonapi_error(401, "Token expired.")
|
|
)
|
|
mock_router.add(
|
|
"GET",
|
|
PROVIDER,
|
|
json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)),
|
|
)
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
result = await client.call_tool(
|
|
"prowler_connect_provider",
|
|
{"provider_uid": "123456789012", "provider_type": "aws"},
|
|
)
|
|
|
|
assert result.data["connected"] == "not_tested"
|
|
assert "never tested" in result.data["error"]
|
|
|
|
|
|
async def test_a_secret_lookup_failure_does_not_pass_as_having_no_secret(
|
|
mcp_root_server, mock_api_client, mock_router
|
|
):
|
|
"""Returning None here would send the write down the create branch.
|
|
|
|
A provider holds at most one secret, so creating a second one is refused and
|
|
the caller would be told its credentials were rejected when all that
|
|
actually failed was this read.
|
|
"""
|
|
mock_router.add(
|
|
"GET",
|
|
PROVIDERS,
|
|
json=jsonapi_collection(
|
|
[jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)]
|
|
),
|
|
)
|
|
mock_router.add(
|
|
"GET", SECRETS, status=429, json=jsonapi_error(429, "Too many requests.")
|
|
)
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
with pytest.raises(Exception, match="rate limiting"):
|
|
await client.call_tool(
|
|
"prowler_connect_provider",
|
|
{
|
|
"provider_uid": "123456789012",
|
|
"provider_type": "aws",
|
|
"credentials": {
|
|
"aws_access_key_id": "AKIA",
|
|
"aws_secret_access_key": "s",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert f"POST {SECRETS}" not in mock_router.paths()
|