mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-10 21:44:16 +00:00
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.
312 lines
10 KiB
Python
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
|