Files
prowler/mcp_server/tests/lib/test_errors.py
Rubén De la Torre Vico c37e2b90dc feat(mcp): add a single vocabulary for tool failures
Adds lib/errors.py: the exception types the sub-servers raise for a
failure they can describe -- an API rejection, a task that never
finished, bad credentials, a Hub outage -- and render_tool_error, the
one place any of them becomes text a client reads.

Nothing imports it yet. The wrapper that turns these into an MCP error
on every tool, and the servers that raise them, follow in this stack.
2026-08-12 13:16:38 +02:00

312 lines
10 KiB
Python

"""Tests for the sentence a failure is described with.
These assert on the *text* a model reads, because that text is the whole contract: a
`ToolError` carries nothing else. What matters is that the API's own words survive
intact, and that a write whose outcome nobody can report says so.
How that sentence reaches a client -- and that no tool can escape it -- is
`test_server.py`.
"""
import httpx
import pytest
from prowler_mcp_server.lib.errors import (
ApiErrorDetail,
ProwlerAPIError,
ProwlerAuthError,
ProwlerHubError,
ProwlerTaskError,
parse_jsonapi_errors,
render_tool_error,
)
MAY_HAVE_LANDED = "It may have been carried out anyway"
# --------------------------------------------------------------------------- parsing
def test_every_error_of_the_document_is_preserved():
"""A rejected write names one error per invalid field; all of them matter."""
errors = parse_jsonapi_errors(
{
"errors": [
{"status": "400", "detail": "This field may not be blank."},
{"status": "400", "detail": "Enter a valid URL."},
]
}
)
assert [error.detail for error in errors] == [
"This field may not be blank.",
"Enter a valid URL.",
]
def test_a_query_parameter_error_is_not_dressed_up_as_a_body_path():
"""`source.parameter` and `source.pointer` are different places, per JSON:API.
The API sends a parameter for a bad query string (`api/v1/views.py` answers
`page[size]` and `lookback_days` that way) and a pointer for a bad body field.
Rendering a parameter bare would read as though `page[size]` were a path into the
document, which is somewhere the caller never put it.
"""
(error,) = parse_jsonapi_errors(
{
"errors": [
{
"detail": "invalid parameter 'page[size]'",
"source": {"parameter": "page[size]"},
}
]
}
)
assert error.parameter == "page[size]"
assert error.pointer is None
assert error.render() == "invalid parameter 'page[size]' (parameter page[size])"
def test_the_field_an_error_points_at_is_kept():
"""`source.pointer` is what turns "invalid" into "this field is invalid"."""
(error,) = parse_jsonapi_errors(
{
"errors": [
{
"detail": "This field may not be blank.",
"source": {"pointer": "/data/attributes/name"},
}
]
}
)
assert error.render() == ("This field may not be blank. (/data/attributes/name)")
def test_an_error_with_only_a_title_still_says_something():
"""`detail` is the useful field, but the API does not always send one."""
assert ApiErrorDetail(title="Not Found").render() == "Not Found"
@pytest.mark.parametrize(
"payload",
[None, "not a document", {}, {"errors": "not a list"}, {"errors": [None]}],
ids=["none", "text", "empty", "errors-not-a-list", "member-not-a-dict"],
)
def test_a_body_that_is_not_an_error_document_is_tolerated(payload):
"""Parsing runs while handling a failure; it must not become the failure."""
assert parse_jsonapi_errors(payload) == ()
def test_an_error_with_nothing_in_it_renders_empty():
"""Rendered to nothing rather than to punctuation, so composition can skip it."""
assert ApiErrorDetail().render() == ""
# --------------------------------------------------------------------- api failures
def test_a_failed_read_names_the_call_and_the_reason():
message = render_tool_error(
ProwlerAPIError(
"API request failed: 404 - Not found.",
404,
method="GET",
path="/findings/nope",
errors=parse_jsonapi_errors(
{"errors": [{"detail": "No Finding matches the given query."}]}
),
)
)
assert message == (
"GET /findings/nope failed with HTTP 404. No Finding matches the given query."
)
def test_every_error_of_a_rejected_write_reaches_the_client():
"""The API rejects a write with one error per invalid field, and all of them help."""
message = render_tool_error(
ProwlerAPIError(
"API request failed: 400 - blank",
400,
method="POST",
path="/integrations",
errors=parse_jsonapi_errors(
{
"errors": [
{
"detail": "This field may not be blank.",
"source": {"pointer": "/data/attributes/bucket_name"},
},
{"detail": "Enter a valid URL."},
]
}
),
)
)
assert message == (
"POST /integrations failed with HTTP 400. "
"This field may not be blank. (/data/attributes/bucket_name); "
"Enter a valid URL."
)
def test_a_rejected_write_gets_no_warning():
"""A 4xx changed nothing, so there is nothing to warn about."""
message = render_tool_error(
ProwlerAPIError("boom", 400, method="POST", path="/scans")
)
assert MAY_HAVE_LANDED not in message
def test_a_write_that_hit_a_server_error_warns_it_may_have_landed():
"""The API validates and queues before answering, so a 500 may have gone through."""
message = render_tool_error(
ProwlerAPIError("boom", 500, method="DELETE", path="/integrations/i1")
)
assert message.startswith("DELETE /integrations/i1 failed with HTTP 500.")
assert MAY_HAVE_LANDED in message
def test_a_failed_read_never_warns():
"""A read cannot have changed anything, whatever went wrong."""
message = render_tool_error(
ProwlerAPIError("boom", 500, method="GET", path="/scans")
)
assert MAY_HAVE_LANDED not in message
# ------------------------------------------------------------------ no answer at all
def test_a_write_that_got_no_answer_warns_it_may_have_landed():
"""A timeout is the case the warning exists for."""
request = httpx.Request("POST", "https://api.testing.invalid/api/v1/scans")
message = render_tool_error(httpx.ReadTimeout("timed out", request=request))
assert "POST https://api.testing.invalid/api/v1/scans got no answer" in message
assert "ReadTimeout" in message
assert MAY_HAVE_LANDED in message
def test_a_read_that_got_no_answer_does_not_warn():
request = httpx.Request("GET", "https://api.testing.invalid/api/v1/findings")
message = render_tool_error(httpx.ReadTimeout("timed out", request=request))
assert MAY_HAVE_LANDED not in message
def test_a_dropped_connection_names_what_went_wrong():
request = httpx.Request("POST", "https://api.testing.invalid/api/v1/scans")
message = render_tool_error(httpx.ConnectError("connection reset", request=request))
assert "ConnectError: connection reset" in message
def test_an_unfinished_task_warns_it_may_have_landed():
"""The API already accepted the work, so the outcome is open, not refused."""
message = render_tool_error(
ProwlerTaskError(
"Task t1 polling timed out after 60 seconds.", task_id="t1", state="timeout"
)
)
assert message.startswith("Task t1 polling timed out after 60 seconds.")
assert MAY_HAVE_LANDED in message
# -------------------------------------------------------------- refusals before send
def test_a_stray_value_error_is_reported_as_a_bug():
"""The distinction the previous passthrough branch could not make.
A model factory rejecting an API payload, or an `int()` on something that is not a
number, is not the caller's mistake. Describing it like a validation message sends
an agent off rewriting arguments that were never the problem.
"""
message = render_tool_error(
ValueError("Missing pagination metadata in API response")
)
assert "unexpected ValueError" in message
assert "bug in the server" in message
def test_an_authentication_failure_says_so():
"""A `ValueError` subclass, so it must be recognised before the generic branch."""
assert render_tool_error(ProwlerAuthError("Token has expired")) == (
"Prowler authentication failed: Token has expired"
)
# ---------------------------------------------------------------------- other hosts
def test_a_hub_failure_reads_like_an_api_failure():
"""Same sentence as the Prowler API, with the service named.
The Hub can be down while the API is fine, so which one failed is worth the two
extra words -- but the shape must not differ, or the two look like two contracts.
"""
message = render_tool_error(
ProwlerHubError(
"hub failed",
status_code=404,
path="/check/test",
body='{"error": "Not found"}',
)
)
assert message == "Prowler Hub GET /check/test failed with HTTP 404. Not found"
@pytest.mark.parametrize(
("body", "expected"),
[
('{"error": "Not found"}', "Not found"),
('{"message": "Bad gateway"}', "Bad gateway"),
('{"detail": "Rate limited"}', "Rate limited"),
("Service Unavailable", "Service Unavailable"),
('{"unexpected": "shape"}', '{"unexpected": "shape"}'),
],
ids=["error", "message", "detail", "plain-text", "unknown-json"],
)
def test_an_upstream_message_is_pulled_out_of_whatever_shape_it_came_in(body, expected):
"""Hosts that are not the Prowler API each have their own error shape.
Relaying the raw body puts JSON braces, or a whole HTML page, in front of the model.
"""
message = render_tool_error(
ProwlerHubError("hub failed", status_code=500, path="/checks", body=body)
)
assert message.endswith(expected)
def test_an_upstream_body_is_truncated():
"""An HTML error page must not flood the model's context."""
message = render_tool_error(
ProwlerHubError("hub failed", status_code=500, path="/checks", body="x" * 2000)
)
assert "x" * 500 in message
assert "x" * 501 not in message
# --------------------------------------------------------------------- server bugs
def test_a_bug_in_this_server_is_reported_as_a_bug():
"""Named as ours, so the caller stops trying to fix it by changing arguments."""
message = render_tool_error(KeyError("attributes"))
assert "unexpected KeyError" in message
assert "bug in the server" in message