diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 8dc4c31a88..82fcdfc075 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4375,15 +4375,21 @@ class TestResourceViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["code"] == "invalid_provider" + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "invalid_provider" + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["pointer"] == "/data/attributes/provider" + assert "AWS" in error["detail"] @pytest.mark.parametrize( - "lookback_days,expected_status,expected_code", + "lookback_days,expected_status,expected_code,expected_detail_contains", [ - ("abc", status.HTTP_400_BAD_REQUEST, "invalid"), # Non-integer - ("0", status.HTTP_400_BAD_REQUEST, "out_of_range"), # Below min - ("91", status.HTTP_400_BAD_REQUEST, "out_of_range"), # Above max (90) - ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range"), # Negative + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), ], ) def test_events_invalid_lookback_days( @@ -4393,8 +4399,9 @@ class TestResourceViewSet: lookback_days, expected_status, expected_code, + expected_detail_contains, ): - """Test events endpoint validates lookback_days parameter.""" + """Test events endpoint validates lookback_days with JSON:API compliant errors.""" from api.models import Resource aws_provider = providers_fixture[0] # AWS provider from fixture @@ -4415,7 +4422,60 @@ class TestResourceViewSet: ) assert response.status_code == expected_status - assert response.json()["errors"][0]["code"] == expected_code + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "lookback_days" + assert expected_detail_contains in error["detail"] + + @pytest.mark.parametrize( + "page_size,expected_status,expected_code,expected_detail_contains", + [ + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ], + ) + def test_events_invalid_page_size( + self, + authenticated_client, + providers_fixture, + page_size, + expected_status, + expected_code, + expected_detail_contains, + ): + """Test events endpoint validates page[size] with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"page[size]": page_size}, + ) + + assert response.status_code == expected_status + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "page[size]" + assert expected_detail_contains in error["detail"] @patch("api.v1.views.initialize_prowler_provider") @patch("api.v1.views.CloudTrailTimeline") @@ -4485,6 +4545,14 @@ class TestResourceViewSet: events = response_data["data"] assert len(events) == 2 + + # Verify JSON:API structure: type and id are present + assert events[0]["type"] == "resource-events" + assert events[0]["id"] == "event-1-id" + assert events[1]["type"] == "resource-events" + assert events[1]["id"] == "event-2-id" + + # Verify attributes assert events[0]["attributes"]["event_name"] == "RunInstances" assert events[0]["attributes"]["actor"] == "admin@example.com" assert events[1]["attributes"]["event_name"] == "StopInstances" @@ -4582,7 +4650,12 @@ class TestResourceViewSet: # 502 because this is an upstream auth failure, not API auth failure assert response.status_code == status.HTTP_502_BAD_GATEWAY - assert response.json()["errors"][0]["code"] == "upstream_auth_failed" + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_auth_failed" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error @patch("api.v1.views.initialize_prowler_provider") @patch("api.v1.views.CloudTrailTimeline") @@ -4628,7 +4701,12 @@ class TestResourceViewSet: # AccessDenied returns 502 (upstream error, not user's fault) assert response.status_code == status.HTTP_502_BAD_GATEWAY - assert response.json()["errors"][0]["code"] == "upstream_access_denied" + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_access_denied" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error @patch("api.v1.views.initialize_prowler_provider") @patch("api.v1.views.CloudTrailTimeline") @@ -4674,7 +4752,12 @@ class TestResourceViewSet: # Non-AccessDenied errors return 503 assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE - assert response.json()["errors"][0]["code"] == "service_unavailable" + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "service_unavailable" + assert error["status"] == "503" # Must be string per JSON:API spec + assert "detail" in error def test_events_unauthenticated_returns_401(self, providers_fixture): """Test events endpoint returns 401 when no credentials are provided. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 04be05baa1..e6cb1357e5 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -2922,8 +2922,14 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): lookback_days = int(lookback_days_str) except (ValueError, TypeError): raise ValidationError( - {"lookback_days": "Must be a valid integer"}, - code="invalid", + [ + { + "detail": "lookback_days must be a valid integer", + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "invalid", + } + ] ) if not ( @@ -2932,13 +2938,17 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): <= self.EVENTS_MAX_LOOKBACK_DAYS ): raise ValidationError( - { - "lookback_days": ( - f"Must be between {self.EVENTS_MIN_LOOKBACK_DAYS} " - f"and {self.EVENTS_MAX_LOOKBACK_DAYS}" - ) - }, - code="out_of_range", + [ + { + "detail": ( + f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} " + f"and {self.EVENTS_MAX_LOOKBACK_DAYS}" + ), + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "out_of_range", + } + ] ) # Validate and parse page[size] from query params (JSON:API pagination) @@ -2950,21 +2960,31 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): page_size = int(page_size_str) except (ValueError, TypeError): raise ValidationError( - {"page[size]": "Must be a valid integer"}, - code="invalid", + [ + { + "detail": "page[size] must be a valid integer", + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "invalid", + } + ] ) if not ( self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE ): raise ValidationError( - { - "page[size]": ( - f"Must be between {self.EVENTS_MIN_PAGE_SIZE} " - f"and {self.EVENTS_MAX_PAGE_SIZE}" - ) - }, - code="out_of_range", + [ + { + "detail": ( + f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} " + f"and {self.EVENTS_MAX_PAGE_SIZE}" + ), + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "out_of_range", + } + ] ) # Parse include_read_events (default: false)