mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
fix(api): support finding-group aggregated filters
- Apply status and severity filters on aggregated finding-group results - Prefilter summary groups by finding-level filter matches for advanced dimensions - Add coverage for latest/list filters and keep check_title sorting support
This commit is contained in:
@@ -15,6 +15,7 @@ from django_filters.rest_framework import (
|
||||
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.constants import SEVERITY_ORDER
|
||||
from api.db_utils import (
|
||||
FindingDeltaEnumField,
|
||||
InvitationStateEnumField,
|
||||
@@ -803,11 +804,17 @@ class FindingGroupFilter(CommonFindingFilters):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(
|
||||
field_name="check_metadata__CheckTitle", lookup_expr="icontains"
|
||||
)
|
||||
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
|
||||
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = {
|
||||
"check_id": ["exact", "in", "icontains"],
|
||||
"scan": ["exact", "in"],
|
||||
}
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
@@ -895,11 +902,17 @@ class LatestFindingGroupFilter(CommonFindingFilters):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(
|
||||
field_name="check_metadata__CheckTitle", lookup_expr="icontains"
|
||||
)
|
||||
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
|
||||
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = {
|
||||
"check_id": ["exact", "in", "icontains"],
|
||||
"scan": ["exact", "in"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1048,6 +1061,91 @@ class LatestFindingGroupSummaryFilter(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class FindingGroupAggregatedComputedFilter(FilterSet):
|
||||
"""Filter aggregated finding-group rows by computed status/severity."""
|
||||
|
||||
STATUS_CHOICES = (
|
||||
("FAIL", "Fail"),
|
||||
("PASS", "Pass"),
|
||||
("MUTED", "Muted"),
|
||||
)
|
||||
|
||||
status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES)
|
||||
status__in = CharInFilter(method="filter_status_in", lookup_expr="in")
|
||||
severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices)
|
||||
severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in")
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
return queryset.filter(aggregated_status=value)
|
||||
|
||||
def filter_status_in(self, queryset, name, value):
|
||||
values = value
|
||||
if isinstance(value, str):
|
||||
values = [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
allowed = {choice[0] for choice in self.STATUS_CHOICES}
|
||||
invalid = [
|
||||
status_value for status_value in values if status_value not in allowed
|
||||
]
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid status filter: {invalid[0]}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if not values:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(aggregated_status__in=values)
|
||||
|
||||
def filter_severity(self, queryset, name, value):
|
||||
severity_order = SEVERITY_ORDER.get(value)
|
||||
if severity_order is None:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid severity filter: {value}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
return queryset.filter(severity_order=severity_order)
|
||||
|
||||
def filter_severity_in(self, queryset, name, value):
|
||||
values = value
|
||||
if isinstance(value, str):
|
||||
values = [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
orders = []
|
||||
for severity_value in values:
|
||||
severity_order = SEVERITY_ORDER.get(severity_value)
|
||||
if severity_order is None:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid severity filter: {severity_value}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
orders.append(severity_order)
|
||||
|
||||
if not orders:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(severity_order__in=orders)
|
||||
|
||||
|
||||
class ProviderSecretFilter(FilterSet):
|
||||
inserted_at = DateFilter(
|
||||
field_name="inserted_at",
|
||||
|
||||
@@ -1055,9 +1055,9 @@ class TestProviderViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
|
||||
def test_providers_retrieve(self, authenticated_client, providers_fixture):
|
||||
provider1, *_ = providers_fixture
|
||||
@@ -4919,13 +4919,13 @@ class TestAttackPathsScanViewSet:
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
if i < 10:
|
||||
assert (
|
||||
response.status_code == status.HTTP_200_OK
|
||||
), f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
|
||||
)
|
||||
else:
|
||||
assert (
|
||||
response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
), f"Request {i + 1} should be throttled"
|
||||
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS, (
|
||||
f"Request {i + 1} should be throttled"
|
||||
)
|
||||
|
||||
# -- Timeout simulation -------------------------------------------------------
|
||||
|
||||
@@ -5128,9 +5128,9 @@ class TestResourceViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
@@ -5679,9 +5679,9 @@ class TestResourceViewSet:
|
||||
(e for e in errors if e["source"]["parameter"] == expected_invalid_param),
|
||||
None,
|
||||
)
|
||||
assert (
|
||||
error is not None
|
||||
), f"Expected error for parameter '{expected_invalid_param}'"
|
||||
assert error is not None, (
|
||||
f"Expected error for parameter '{expected_invalid_param}'"
|
||||
)
|
||||
assert error["code"] == "invalid"
|
||||
assert error["status"] == "400" # Must be string per JSON:API spec
|
||||
assert expected_invalid_param in error["detail"]
|
||||
@@ -6213,16 +6213,16 @@ class TestResourceViewSet:
|
||||
# Test with completely malformed token
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token")
|
||||
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
|
||||
assert (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
), f"Expected 401 for malformed token but got {response.status_code}"
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f"Expected 401 for malformed token but got {response.status_code}"
|
||||
)
|
||||
|
||||
# Test with empty bearer token
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer ")
|
||||
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
|
||||
assert (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
), f"Expected 401 for empty bearer token but got {response.status_code}"
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f"Expected 401 for empty bearer token but got {response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -6283,9 +6283,9 @@ class TestFindingViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
@@ -6824,9 +6824,9 @@ class TestJWTFields:
|
||||
reverse("token-obtain"), data, format="json"
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == status.HTTP_200_OK
|
||||
), f"Unexpected status code: {response.status_code}"
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Unexpected status code: {response.status_code}"
|
||||
)
|
||||
|
||||
access_token = response.data["attributes"]["access"]
|
||||
payload = jwt.decode(access_token, options={"verify_signature": False})
|
||||
@@ -6840,23 +6840,23 @@ class TestJWTFields:
|
||||
# Verify expected fields
|
||||
for field in expected_fields:
|
||||
assert field in payload, f"The field '{field}' is not in the JWT"
|
||||
assert (
|
||||
payload[field] == expected_fields[field]
|
||||
), f"The value of '{field}' does not match"
|
||||
assert payload[field] == expected_fields[field], (
|
||||
f"The value of '{field}' does not match"
|
||||
)
|
||||
|
||||
# Verify time fields are integers
|
||||
for time_field in ["exp", "iat", "nbf"]:
|
||||
assert time_field in payload, f"The field '{time_field}' is not in the JWT"
|
||||
assert isinstance(
|
||||
payload[time_field], int
|
||||
), f"The field '{time_field}' is not an integer"
|
||||
assert isinstance(payload[time_field], int), (
|
||||
f"The field '{time_field}' is not an integer"
|
||||
)
|
||||
|
||||
# Verify identification fields are non-empty strings
|
||||
for id_field in ["jti", "sub", "tenant_id"]:
|
||||
assert id_field in payload, f"The field '{id_field}' is not in the JWT"
|
||||
assert (
|
||||
isinstance(payload[id_field], str) and payload[id_field]
|
||||
), f"The field '{id_field}' is not a valid string"
|
||||
assert isinstance(payload[id_field], str) and payload[id_field], (
|
||||
f"The field '{id_field}' is not a valid string"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -10795,9 +10795,9 @@ class TestIntegrationViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"integration_type, configuration, credentials",
|
||||
@@ -12234,9 +12234,9 @@ class TestLighthouseConfigViewSet:
|
||||
)
|
||||
# Check that API key is masked with asterisks only
|
||||
masked_api_key = data["attributes"]["api_key"]
|
||||
assert all(
|
||||
c == "*" for c in masked_api_key
|
||||
), "API key should contain only asterisks"
|
||||
assert all(c == "*" for c in masked_api_key), (
|
||||
"API key should contain only asterisks"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, invalid_value",
|
||||
@@ -15245,6 +15245,85 @@ class TestFindingGroupViewSet:
|
||||
# rds_encryption has all muted findings
|
||||
assert data[0]["attributes"]["status"] == "MUTED"
|
||||
|
||||
def test_finding_groups_status_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test finding groups can be filtered by aggregated status."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{"filter[inserted_at]": TODAY, "filter[status]": "FAIL"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["status"] == "FAIL" for item in data)
|
||||
|
||||
def test_finding_groups_status_in_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test finding groups support status__in filter on aggregated status."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{"filter[inserted_at]": TODAY, "filter[status__in]": "FAIL,PASS"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)
|
||||
|
||||
def test_finding_groups_severity_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test finding groups can be filtered by aggregated severity."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{"filter[inserted_at]": TODAY, "filter[severity]": "critical"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["severity"] == "critical" for item in data)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name,filter_value",
|
||||
[
|
||||
("region", "__region_does_not_exist__"),
|
||||
("service", "__service_does_not_exist__"),
|
||||
("category", "__category_does_not_exist__"),
|
||||
("resource_groups", "__group_does_not_exist__"),
|
||||
("resource_type", "__type_does_not_exist__"),
|
||||
("scan", "00000000-0000-7000-8000-000000000001"),
|
||||
],
|
||||
)
|
||||
def test_finding_groups_finding_level_filters_are_applied(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_fixture,
|
||||
filter_name,
|
||||
filter_value,
|
||||
):
|
||||
"""Test finding-level filters are applied in /finding-groups aggregation."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{"filter[inserted_at]": TODAY, f"filter[{filter_name}]": filter_value},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 0
|
||||
|
||||
def test_finding_groups_delta_filter_is_applied(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test delta filter is applied in /finding-groups aggregation."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{"filter[inserted_at]": TODAY, "filter[delta]": "new"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["new_count"] > 0 for item in data)
|
||||
|
||||
def test_finding_groups_provider_aggregation(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
@@ -15704,12 +15783,12 @@ class TestFindingGroupViewSet:
|
||||
assert response_p1.status_code == status.HTTP_200_OK
|
||||
p1_check_ids = {item["id"] for item in response_p1.json()["data"]}
|
||||
# Provider1 has scan1 with 4 checks
|
||||
assert (
|
||||
len(p1_check_ids) == 4
|
||||
), f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
|
||||
assert (
|
||||
"cloudtrail_enabled" not in p1_check_ids
|
||||
), "cloudtrail_enabled should NOT be in provider1"
|
||||
assert len(p1_check_ids) == 4, (
|
||||
f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
|
||||
)
|
||||
assert "cloudtrail_enabled" not in p1_check_ids, (
|
||||
"cloudtrail_enabled should NOT be in provider1"
|
||||
)
|
||||
|
||||
# Get finding groups for provider2 only
|
||||
response_p2 = authenticated_client.get(
|
||||
@@ -15719,12 +15798,12 @@ class TestFindingGroupViewSet:
|
||||
assert response_p2.status_code == status.HTTP_200_OK
|
||||
p2_check_ids = {item["id"] for item in response_p2.json()["data"]}
|
||||
# Provider2 has scan2 with 1 check
|
||||
assert (
|
||||
len(p2_check_ids) == 1
|
||||
), f"Provider2 should have 1 check, got {len(p2_check_ids)}"
|
||||
assert (
|
||||
"cloudtrail_enabled" in p2_check_ids
|
||||
), "cloudtrail_enabled should be in provider2"
|
||||
assert len(p2_check_ids) == 1, (
|
||||
f"Provider2 should have 1 check, got {len(p2_check_ids)}"
|
||||
)
|
||||
assert "cloudtrail_enabled" in p2_check_ids, (
|
||||
"cloudtrail_enabled should be in provider2"
|
||||
)
|
||||
|
||||
# Test provider_type filter actually filters data
|
||||
def test_finding_groups_provider_type_filter_actually_filters(
|
||||
@@ -15747,9 +15826,9 @@ class TestFindingGroupViewSet:
|
||||
{"filter[inserted_at]": TODAY, "filter[provider_type]": "gcp"},
|
||||
)
|
||||
assert response_gcp.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
len(response_gcp.json()["data"]) == 0
|
||||
), "GCP filter should return 0 results"
|
||||
assert len(response_gcp.json()["data"]) == 0, (
|
||||
"GCP filter should return 0 results"
|
||||
)
|
||||
|
||||
def test_finding_groups_pagination(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
@@ -15853,6 +15932,116 @@ class TestFindingGroupViewSet:
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "cloudtrail_enabled"
|
||||
|
||||
def test_finding_groups_latest_status_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest supports status filter on aggregated status."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"filter[status]": "FAIL"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["status"] == "FAIL" for item in data)
|
||||
|
||||
def test_finding_groups_latest_status_in_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest supports status__in filter on aggregated status."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"filter[status__in]": "FAIL,PASS"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)
|
||||
|
||||
def test_finding_groups_latest_severity_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest supports severity filter on aggregated severity."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"filter[severity]": "critical"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["severity"] == "critical" for item in data)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name,filter_value",
|
||||
[
|
||||
("region", "__region_does_not_exist__"),
|
||||
("service", "__service_does_not_exist__"),
|
||||
("category", "__category_does_not_exist__"),
|
||||
("resource_groups", "__group_does_not_exist__"),
|
||||
("resource_type", "__type_does_not_exist__"),
|
||||
("scan", "00000000-0000-7000-8000-000000000001"),
|
||||
],
|
||||
)
|
||||
def test_finding_groups_latest_finding_level_filters_are_applied(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_fixture,
|
||||
filter_name,
|
||||
filter_value,
|
||||
):
|
||||
"""Test finding-level filters are applied in /finding-groups/latest aggregation."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{f"filter[{filter_name}]": filter_value},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 0
|
||||
|
||||
def test_finding_groups_check_title_filter_applies_with_delta(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test check_title filter is honored when finding-level path is used."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{
|
||||
"filter[inserted_at]": TODAY,
|
||||
"filter[delta]": "new",
|
||||
"filter[check_title.icontains]": "__missing_check_title__",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 0
|
||||
|
||||
def test_finding_groups_latest_check_title_filter_applies_with_delta(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest check_title filter is honored on finding-level path."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{
|
||||
"filter[delta]": "new",
|
||||
"filter[check_title.icontains]": "__missing_check_title__",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 0
|
||||
|
||||
def test_finding_groups_latest_delta_filter_is_applied(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test delta filter is applied in /finding-groups/latest aggregation."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"filter[delta]": "new"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
assert all(item["attributes"]["new_count"] > 0 for item in data)
|
||||
|
||||
def test_finding_groups_latest_aggregates_latest_per_provider(
|
||||
self, authenticated_client, providers_fixture
|
||||
):
|
||||
@@ -15934,6 +16123,19 @@ class TestFindingGroupViewSet:
|
||||
check_ids = [item["id"] for item in data]
|
||||
assert check_ids == sorted(check_ids)
|
||||
|
||||
def test_finding_groups_latest_sort_by_check_title(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest supports sorting by check_title."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"sort": "check_title"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
check_titles = [item["attributes"]["check_title"] for item in data]
|
||||
assert check_titles == sorted(check_titles)
|
||||
|
||||
def test_finding_groups_latest_ignores_date_filters(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
|
||||
@@ -31,6 +31,7 @@ from django.contrib.postgres.search import SearchQuery
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
@@ -124,6 +125,7 @@ from api.filters import (
|
||||
CustomDjangoFilterBackend,
|
||||
DailySeveritySummaryFilter,
|
||||
FindingFilter,
|
||||
FindingGroupAggregatedComputedFilter,
|
||||
FindingGroupFilter,
|
||||
FindingGroupSummaryFilter,
|
||||
IntegrationFilter,
|
||||
@@ -6872,6 +6874,39 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"resource_type__icontains": "type__icontains",
|
||||
}
|
||||
|
||||
def _get_finding_level_filter_keys(self, latest: bool = False) -> set[str]:
|
||||
"""Derive filters that require finding-level prefiltering."""
|
||||
summary_filterset = (
|
||||
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
|
||||
)
|
||||
finding_filterset = LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
|
||||
summary_supported = set(summary_filterset.base_filters.keys()) | {
|
||||
"status",
|
||||
"status__in",
|
||||
"severity",
|
||||
"severity__in",
|
||||
}
|
||||
finding_supported = set(finding_filterset.base_filters.keys())
|
||||
return finding_supported - summary_supported
|
||||
|
||||
def _requires_finding_level_aggregation(
|
||||
self, params: QueryDict, latest: bool = False
|
||||
) -> bool:
|
||||
finding_level_keys = self._get_finding_level_filter_keys(latest=latest)
|
||||
return any(key in finding_level_keys for key in params.keys())
|
||||
|
||||
def _get_matching_check_ids_from_findings(
|
||||
self, normalized_params: QueryDict, latest: bool = False
|
||||
):
|
||||
"""Return check_ids matching advanced finding-level filters."""
|
||||
finding_queryset = self._get_finding_queryset()
|
||||
filterset_class = LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
filterset = filterset_class(normalized_params, queryset=finding_queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
return filterset.qs.values("check_id").distinct()
|
||||
|
||||
def _split_resource_filters(self, params: QueryDict) -> tuple[QueryDict, QueryDict]:
|
||||
resource_keys = set(self.RESOURCE_FILTER_MAP)
|
||||
finding_params = QueryDict(mutable=True)
|
||||
@@ -6998,6 +7033,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"""Validate and map JSON:API sort fields for aggregated finding groups."""
|
||||
sort_field_map = {
|
||||
"check_id": "check_id",
|
||||
"check_title": "check_title",
|
||||
"severity": "severity_order",
|
||||
"fail_count": "fail_count",
|
||||
"pass_count": "pass_count",
|
||||
@@ -7035,6 +7071,43 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
return ordering
|
||||
|
||||
def _apply_aggregated_computed_filters(
|
||||
self, queryset, normalized_params: QueryDict
|
||||
):
|
||||
"""Apply computed filters (status/severity) on aggregated finding-group rows."""
|
||||
status_params = QueryDict(mutable=True)
|
||||
if normalized_params.get("status"):
|
||||
status_params.setlist("status", [normalized_params.get("status")])
|
||||
if normalized_params.getlist("status__in"):
|
||||
status_params.setlist("status__in", normalized_params.getlist("status__in"))
|
||||
if normalized_params.get("severity"):
|
||||
status_params.setlist("severity", [normalized_params.get("severity")])
|
||||
if normalized_params.getlist("severity__in"):
|
||||
status_params.setlist(
|
||||
"severity__in", normalized_params.getlist("severity__in")
|
||||
)
|
||||
|
||||
if not status_params:
|
||||
return queryset
|
||||
|
||||
if status_params.get("status") or status_params.getlist("status__in"):
|
||||
queryset = queryset.annotate(
|
||||
aggregated_status=Case(
|
||||
When(fail_count__gt=0, then=Value("FAIL")),
|
||||
When(pass_count__gt=0, then=Value("PASS")),
|
||||
default=Value("MUTED"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
|
||||
filterset = FindingGroupAggregatedComputedFilter(
|
||||
status_params, queryset=queryset
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
|
||||
return filterset.qs
|
||||
|
||||
def _build_resource_mapping_queryset(
|
||||
self, filtered_queryset, resource_ids=None, tenant_id: str | None = None
|
||||
):
|
||||
@@ -7166,6 +7239,17 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
# Re-aggregate daily summaries across the date range
|
||||
aggregated_queryset = self._aggregate_daily_summaries(filtered_queryset)
|
||||
aggregated_queryset = self._apply_aggregated_computed_filters(
|
||||
aggregated_queryset, normalized_params
|
||||
)
|
||||
|
||||
if self._requires_finding_level_aggregation(normalized_params, latest=False):
|
||||
matching_check_ids = self._get_matching_check_ids_from_findings(
|
||||
normalized_params, latest=False
|
||||
)
|
||||
aggregated_queryset = aggregated_queryset.filter(
|
||||
check_id__in=Subquery(matching_check_ids)
|
||||
)
|
||||
|
||||
# Apply ordering (respect JSON:API sort param or use default)
|
||||
sort_param = request.query_params.get("sort")
|
||||
@@ -7239,6 +7323,17 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
# Re-aggregate daily summaries
|
||||
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)
|
||||
aggregated_queryset = self._apply_aggregated_computed_filters(
|
||||
aggregated_queryset, normalized_params
|
||||
)
|
||||
|
||||
if self._requires_finding_level_aggregation(normalized_params, latest=True):
|
||||
matching_check_ids = self._get_matching_check_ids_from_findings(
|
||||
normalized_params, latest=True
|
||||
)
|
||||
aggregated_queryset = aggregated_queryset.filter(
|
||||
check_id__in=Subquery(matching_check_ids)
|
||||
)
|
||||
|
||||
# Apply ordering
|
||||
sort_param = request.query_params.get("sort")
|
||||
|
||||
Reference in New Issue
Block a user