mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-10 13:32:44 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3dca2c9c4 | |||
| 54fe2cfa64 |
+2
-13
@@ -2,28 +2,16 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.24.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
|
||||
|
||||
## [1.23.0] (Prowler v5.22.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
|
||||
## [1.23.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
|
||||
- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
|
||||
- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
|
||||
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +28,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355)
|
||||
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
|
||||
- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
Generated
+40
-23
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2711,6 +2711,24 @@ files = [
|
||||
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.3.1"
|
||||
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"},
|
||||
{file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
wrapt = ">=1.10,<3"
|
||||
|
||||
[package.extras]
|
||||
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "detect-secrets"
|
||||
version = "1.5.0"
|
||||
@@ -2965,7 +2983,7 @@ files = [
|
||||
[package.dependencies]
|
||||
autopep8 = "*"
|
||||
Django = ">=4.2"
|
||||
gprof2dot = ">=2017.09.19"
|
||||
gprof2dot = ">=2017.9.19"
|
||||
sqlparse = "*"
|
||||
|
||||
[[package]]
|
||||
@@ -3352,14 +3370,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
version = "3.1.2"
|
||||
description = "A simple framework for building complex web applications."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"},
|
||||
{file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"},
|
||||
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
|
||||
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4573,7 +4591,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -4781,7 +4799,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
|
||||
mongodb = ["pymongo (==4.15.3)"]
|
||||
msgpack = ["msgpack (==1.1.2)"]
|
||||
pyro = ["pyro4 (==4.82)"]
|
||||
qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
|
||||
qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
|
||||
redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
|
||||
slmq = ["softlayer_messaging (>=1.0.3)"]
|
||||
sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
|
||||
@@ -4802,7 +4820,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -6636,7 +6654,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.22.0"
|
||||
version = "5.19.0"
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
optional = false
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
@@ -6694,7 +6712,6 @@ colorama = "0.4.6"
|
||||
cryptography = "44.0.3"
|
||||
dash = "3.1.1"
|
||||
dash-bootstrap-components = "2.0.3"
|
||||
defusedxml = ">=0.7.1"
|
||||
detect-secrets = "1.5.0"
|
||||
dulwich = "0.23.0"
|
||||
google-api-python-client = "2.163.0"
|
||||
@@ -6712,7 +6729,7 @@ pandas = "2.2.3"
|
||||
py-iam-expand = "0.1.0"
|
||||
py-ocsf-models = "0.8.1"
|
||||
pydantic = ">=2.0,<3.0"
|
||||
pygithub = "2.8.0"
|
||||
pygithub = "2.5.0"
|
||||
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
||||
pytz = "2025.1"
|
||||
schema = "0.7.5"
|
||||
@@ -6720,13 +6737,12 @@ shodan = "1.31.0"
|
||||
slack-sdk = "3.39.0"
|
||||
tabulate = "0.9.0"
|
||||
tzlocal = "5.3.1"
|
||||
uuid6 = "2024.7.10"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "41629137efdec1ade078e4386f738c8e0ffce94b"
|
||||
resolved_reference = "b31145616064bd6727139777dca1cea9b977346a"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -7099,21 +7115,22 @@ typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pygithub"
|
||||
version = "2.8.0"
|
||||
version = "2.5.0"
|
||||
description = "Use the full Github API v3"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"},
|
||||
{file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"},
|
||||
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
|
||||
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Deprecated = "*"
|
||||
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
|
||||
pynacl = ">=1.4.0"
|
||||
requests = ">=2.14.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
typing-extensions = ">=4.0.0"
|
||||
urllib3 = ">=1.26.0"
|
||||
|
||||
[[package]]
|
||||
@@ -7165,7 +7182,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.2.2,<=3.3.0-dev0"
|
||||
astroid = ">=3.2.2,<=3.3.0.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
|
||||
@@ -8179,10 +8196,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
@@ -8768,14 +8785,14 @@ test = ["pytest", "websockets"]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.6"
|
||||
version = "3.1.5"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"},
|
||||
{file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"},
|
||||
{file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"},
|
||||
{file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -265,13 +264,6 @@ class CommonFindingFilters(FilterSet):
|
||||
)
|
||||
return queryset.filter(overall_query).distinct()
|
||||
|
||||
def filter_check_title_icontains(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(check_metadata__CheckTitle__icontains=value)
|
||||
| Q(check_metadata__checktitle__icontains=value)
|
||||
| Q(check_metadata__Checktitle__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class TenantFilter(FilterSet):
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
@@ -811,15 +803,11 @@ 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(method="filter_check_title_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):
|
||||
@@ -907,15 +895,11 @@ 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(method="filter_check_title_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"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1064,91 +1048,6 @@ 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",
|
||||
|
||||
@@ -45,6 +45,7 @@ from api.models import (
|
||||
ComplianceRequirementOverview,
|
||||
DailySeveritySummary,
|
||||
Finding,
|
||||
FindingGroupDailySummary,
|
||||
Integration,
|
||||
Invitation,
|
||||
LighthouseProviderConfiguration,
|
||||
@@ -15216,29 +15217,6 @@ class TestFindingGroupViewSet:
|
||||
# ec2_instance_public_ip has 1 PASS and 1 FAIL, should aggregate to FAIL
|
||||
assert data[0]["attributes"]["status"] == "FAIL"
|
||||
|
||||
def test_finding_groups_region_filter_reaggregates_metrics(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test finding-level filters recompute group metrics from matching findings."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-list"),
|
||||
{
|
||||
"filter[inserted_at]": TODAY,
|
||||
"filter[check_id]": "ec2_instance_public_ip",
|
||||
"filter[region]": "us-east-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
|
||||
attrs = data[0]["attributes"]
|
||||
assert attrs["status"] == "PASS"
|
||||
assert attrs["pass_count"] == 1
|
||||
assert attrs["fail_count"] == 0
|
||||
assert attrs["resources_total"] == 1
|
||||
assert attrs["resources_fail"] == 0
|
||||
|
||||
def test_finding_groups_status_pass_when_no_fail(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
@@ -15267,182 +15245,6 @@ 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(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
)
|
||||
def test_finding_groups_combined_region_and_status_filters(
|
||||
self, authenticated_client, finding_groups_fixture, endpoint_name
|
||||
):
|
||||
"""Test combined region + aggregated status filters."""
|
||||
params = {"filter[region]": "us-east-1", "filter[status]": "FAIL"}
|
||||
if endpoint_name == "finding-group-list":
|
||||
params["filter[inserted_at]"] = TODAY
|
||||
|
||||
response = authenticated_client.get(reverse(endpoint_name), params)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
check_ids = {item["id"] for item in data}
|
||||
assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
|
||||
assert all(item["attributes"]["status"] == "FAIL" for item in data)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
)
|
||||
def test_finding_groups_combined_delta_and_severity_filters(
|
||||
self, authenticated_client, finding_groups_fixture, endpoint_name
|
||||
):
|
||||
"""Test combined delta + aggregated severity filters."""
|
||||
params = {"filter[delta]": "new", "filter[severity]": "critical"}
|
||||
if endpoint_name == "finding-group-list":
|
||||
params["filter[inserted_at]"] = TODAY
|
||||
|
||||
response = authenticated_client.get(reverse(endpoint_name), params)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
check_ids = {item["id"] for item in data}
|
||||
assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
|
||||
assert all(item["attributes"]["severity"] == "critical" for item in data)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"filter_key,filter_value",
|
||||
[
|
||||
("status", "INVALID_STATUS"),
|
||||
("severity", "INVALID_SEVERITY"),
|
||||
],
|
||||
)
|
||||
def test_finding_groups_invalid_status_or_severity_returns_400(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_fixture,
|
||||
endpoint_name,
|
||||
filter_key,
|
||||
filter_value,
|
||||
):
|
||||
"""Test invalid aggregated status/severity values are rejected."""
|
||||
params = {f"filter[{filter_key}]": filter_value}
|
||||
if endpoint_name == "finding-group-list":
|
||||
params["filter[inserted_at]"] = TODAY
|
||||
|
||||
response = authenticated_client.get(reverse(endpoint_name), params)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["errors"][0]["code"] == "invalid"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"filter_key,filter_value,expected_detail",
|
||||
[
|
||||
("status__in", "FAIL,INVALID_STATUS", "invalid status filter"),
|
||||
("severity__in", "critical,INVALID_SEVERITY", "invalid severity filter"),
|
||||
],
|
||||
)
|
||||
def test_finding_groups_invalid_in_filters_return_400(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_fixture,
|
||||
endpoint_name,
|
||||
filter_key,
|
||||
filter_value,
|
||||
expected_detail,
|
||||
):
|
||||
"""Test invalid values in status__in/severity__in are rejected."""
|
||||
params = {f"filter[{filter_key}]": filter_value}
|
||||
if endpoint_name == "finding-group-list":
|
||||
params["filter[inserted_at]"] = TODAY
|
||||
|
||||
response = authenticated_client.get(reverse(endpoint_name), params)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
errors = response.json()["errors"]
|
||||
assert errors[0]["code"] == "invalid"
|
||||
assert expected_detail in errors[0]["detail"]
|
||||
|
||||
@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
|
||||
):
|
||||
@@ -16051,257 +15853,47 @@ 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_region_filter_reaggregates_metrics(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest recomputes metrics from findings matching region filter."""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{
|
||||
"filter[check_id]": "ec2_instance_public_ip",
|
||||
"filter[region]": "us-east-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
|
||||
attrs = data[0]["attributes"]
|
||||
assert attrs["status"] == "PASS"
|
||||
assert attrs["pass_count"] == 1
|
||||
assert attrs["fail_count"] == 0
|
||||
assert attrs["resources_total"] == 1
|
||||
assert attrs["resources_fail"] == 0
|
||||
|
||||
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,
|
||||
resources_fixture,
|
||||
self, authenticated_client, providers_fixture
|
||||
):
|
||||
"""Test /latest keeps all findings from the latest scan per provider.
|
||||
|
||||
Verifies that when the latest scan produces multiple findings for the
|
||||
same check_id (e.g. one per resource), all of them are included in the
|
||||
aggregation — not just one.
|
||||
"""
|
||||
"""Test /latest aggregates latest summary from each provider for the same check."""
|
||||
provider1 = providers_fixture[0]
|
||||
provider2 = providers_fixture[1]
|
||||
resource1 = resources_fixture[0]
|
||||
resource2 = resources_fixture[1]
|
||||
resource3 = resources_fixture[2]
|
||||
check_id = "cross_provider_latest_resources_total"
|
||||
|
||||
latest_scan_provider1 = Scan.objects.create(
|
||||
check_id = "cross_provider_latest_resources_total"
|
||||
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
FindingGroupDailySummary.objects.create(
|
||||
tenant_id=provider1.tenant_id,
|
||||
provider=provider1,
|
||||
state=StateChoices.COMPLETED,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
check_id=check_id,
|
||||
inserted_at=now - timedelta(days=1),
|
||||
resources_total=20,
|
||||
resources_fail=20,
|
||||
fail_count=20,
|
||||
)
|
||||
|
||||
latest_scan_provider2 = Scan.objects.create(
|
||||
FindingGroupDailySummary.objects.create(
|
||||
tenant_id=provider2.tenant_id,
|
||||
provider=provider2,
|
||||
state=StateChoices.COMPLETED,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
older_scan_provider1 = Scan.objects.create(
|
||||
tenant_id=provider1.tenant_id,
|
||||
provider=provider1,
|
||||
state=StateChoices.COMPLETED,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
completed_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
|
||||
# Older scan — these should be excluded from /latest
|
||||
Finding.objects.create(
|
||||
tenant_id=provider1.tenant_id,
|
||||
uid="old_cross_provider_1",
|
||||
scan=older_scan_provider1,
|
||||
delta="new",
|
||||
status="FAIL",
|
||||
severity="high",
|
||||
impact="high",
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
|
||||
first_seen_at=datetime.now(timezone.utc) - timedelta(days=2),
|
||||
muted=False,
|
||||
inserted_at=now,
|
||||
resources_total=7,
|
||||
resources_fail=7,
|
||||
fail_count=7,
|
||||
)
|
||||
|
||||
# Latest scan provider1: TWO findings (PASS + FAIL) for the same check
|
||||
latest_p1_pass = Finding.objects.create(
|
||||
tenant_id=provider1.tenant_id,
|
||||
uid="latest_cross_provider_1_pass",
|
||||
scan=latest_scan_provider1,
|
||||
delta="new",
|
||||
status="PASS",
|
||||
severity="high",
|
||||
impact="high",
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
|
||||
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
muted=False,
|
||||
)
|
||||
latest_p1_pass.add_resources([resource1])
|
||||
|
||||
latest_p1_fail = Finding.objects.create(
|
||||
tenant_id=provider1.tenant_id,
|
||||
uid="latest_cross_provider_1_fail",
|
||||
scan=latest_scan_provider1,
|
||||
delta="new",
|
||||
status="FAIL",
|
||||
severity="high",
|
||||
impact="high",
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
|
||||
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
muted=False,
|
||||
)
|
||||
latest_p1_fail.add_resources([resource2])
|
||||
|
||||
# Latest scan provider2: one finding
|
||||
latest_p2 = Finding.objects.create(
|
||||
tenant_id=provider2.tenant_id,
|
||||
uid="latest_cross_provider_2",
|
||||
scan=latest_scan_provider2,
|
||||
delta="new",
|
||||
status="FAIL",
|
||||
severity="high",
|
||||
impact="high",
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
|
||||
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
muted=False,
|
||||
)
|
||||
latest_p2.add_resources([resource3])
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"filter[check_id]": check_id, "filter[delta]": "new"},
|
||||
{"filter[check_id]": check_id},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
attrs = data[0]["attributes"]
|
||||
# 3 findings total: 2 from provider1 latest + 1 from provider2 latest
|
||||
assert attrs["pass_count"] == 1
|
||||
assert attrs["fail_count"] == 2
|
||||
assert attrs["resources_total"] == 3
|
||||
assert attrs["resources_fail"] == 2
|
||||
assert attrs["resources_total"] == 27
|
||||
assert attrs["resources_fail"] == 27
|
||||
assert attrs["fail_count"] == 27
|
||||
|
||||
def test_finding_groups_latest_provider_type_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
@@ -16342,44 +15934,6 @@ 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)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"sort_field",
|
||||
["first_seen_at", "-first_seen_at", "last_seen_at", "failing_since"],
|
||||
)
|
||||
def test_finding_groups_sort_by_time_fields(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_fixture,
|
||||
endpoint_name,
|
||||
sort_field,
|
||||
):
|
||||
"""Test sorting by aggregated time fields (first_seen_at, last_seen_at, failing_since)."""
|
||||
params = {"sort": sort_field}
|
||||
if endpoint_name == "finding-group-list":
|
||||
params["filter[inserted_at]"] = TODAY
|
||||
|
||||
response = authenticated_client.get(reverse(endpoint_name), params)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) > 0
|
||||
|
||||
def test_finding_groups_latest_ignores_date_filters(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
|
||||
@@ -4180,7 +4180,6 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
|
||||
severity = serializers.CharField()
|
||||
first_seen_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
last_seen_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
muted_reason = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "finding-group-resources"
|
||||
|
||||
+107
-223
@@ -31,7 +31,6 @@ from django.contrib.postgres.search import SearchQuery
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
@@ -48,7 +47,7 @@ from django.db.models import (
|
||||
When,
|
||||
Window,
|
||||
)
|
||||
from django.db.models.functions import Cast, Coalesce, DenseRank, RowNumber
|
||||
from django.db.models.functions import Coalesce, RowNumber
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
@@ -125,7 +124,6 @@ from api.filters import (
|
||||
CustomDjangoFilterBackend,
|
||||
DailySeveritySummaryFilter,
|
||||
FindingFilter,
|
||||
FindingGroupAggregatedComputedFilter,
|
||||
FindingGroupFilter,
|
||||
FindingGroupSummaryFilter,
|
||||
IntegrationFilter,
|
||||
@@ -6788,13 +6786,13 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
security analysts to see which checks are failing across their
|
||||
infrastructure without scrolling through thousands of individual findings.
|
||||
|
||||
Uses a hybrid strategy: pre-aggregated daily summaries when possible,
|
||||
and raw findings when finding-level filters require precise subset metrics.
|
||||
Uses pre-aggregated FindingGroupDailySummary table for efficient queries.
|
||||
Daily summaries are re-aggregated across the requested date range.
|
||||
"""
|
||||
|
||||
queryset = FindingGroupDailySummary.objects.all()
|
||||
serializer_class = FindingGroupSerializer
|
||||
filterset_class = FindingGroupFilter
|
||||
filterset_class = FindingGroupSummaryFilter
|
||||
filter_backends = [
|
||||
jsonapi_filters.QueryParameterValidationFilter,
|
||||
jsonapi_filters.OrderingFilter,
|
||||
@@ -6813,12 +6811,12 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
affects the OpenAPI schema generated by drf-spectacular.
|
||||
"""
|
||||
if self.action == "latest":
|
||||
return LatestFindingGroupFilter
|
||||
return LatestFindingGroupSummaryFilter
|
||||
if self.action == "resources":
|
||||
return FindingGroupFilter
|
||||
if self.action == "latest_resources":
|
||||
return LatestFindingGroupFilter
|
||||
return FindingGroupFilter
|
||||
return FindingGroupSummaryFilter
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get the base FindingGroupDailySummary queryset with RLS filtering."""
|
||||
@@ -6925,27 +6923,20 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
return filterset.qs.values("id")
|
||||
|
||||
def _get_finding_level_filter_keys(self, latest: bool = False) -> set[str]:
|
||||
"""Derive filters that require querying raw findings."""
|
||||
summary_filterset = (
|
||||
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
|
||||
)
|
||||
finding_filterset = LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
|
||||
summary_supported = set(summary_filterset.base_filters.keys())
|
||||
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 _aggregate_daily_summaries(self, queryset):
|
||||
"""Re-aggregate summary rows by check_id."""
|
||||
"""
|
||||
Re-aggregate daily summaries across the date range.
|
||||
|
||||
Takes pre-computed daily summaries and aggregates them by check_id
|
||||
to produce totals across the selected date range.
|
||||
"""
|
||||
from django.db.models import CharField
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
return queryset.values("check_id").annotate(
|
||||
# Max severity across days
|
||||
severity_order=Max("severity_order"),
|
||||
# Sum counts across days
|
||||
pass_count=Sum("pass_count"),
|
||||
fail_count=Sum("fail_count"),
|
||||
muted_count=Sum("muted_count"),
|
||||
@@ -6953,99 +6944,22 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
changed_count=Sum("changed_count"),
|
||||
resources_total=Sum("resources_total"),
|
||||
resources_fail=Sum("resources_fail"),
|
||||
# Collect provider types using StringAgg (cast enum to text first)
|
||||
impacted_providers_str=StringAgg(
|
||||
Cast("provider__provider", CharField()),
|
||||
delimiter=",",
|
||||
distinct=True,
|
||||
default="",
|
||||
),
|
||||
agg_first_seen_at=Min("first_seen_at"),
|
||||
agg_last_seen_at=Max("last_seen_at"),
|
||||
agg_failing_since=Min("failing_since"),
|
||||
# Min/Max timing across days
|
||||
first_seen_at=Min("first_seen_at"),
|
||||
last_seen_at=Max("last_seen_at"),
|
||||
failing_since=Min("failing_since"),
|
||||
# Get check metadata from first row (same for all days)
|
||||
check_title=Max("check_title"),
|
||||
check_description=Max("check_description"),
|
||||
)
|
||||
|
||||
def _aggregate_findings(self, queryset):
|
||||
"""Aggregate findings by check_id for finding-group endpoints."""
|
||||
severity_case = Case(
|
||||
*[
|
||||
When(severity=severity, then=Value(order))
|
||||
for severity, order in SEVERITY_ORDER.items()
|
||||
],
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
|
||||
return queryset.values("check_id").annotate(
|
||||
severity_order=Max(severity_case),
|
||||
pass_count=Count("id", filter=Q(status="PASS", muted=False)),
|
||||
fail_count=Count("id", filter=Q(status="FAIL", muted=False)),
|
||||
muted_count=Count("id", filter=Q(muted=True)),
|
||||
new_count=Count("id", filter=Q(delta="new", muted=False)),
|
||||
changed_count=Count("id", filter=Q(delta="changed", muted=False)),
|
||||
resources_total=Count("resources__id", distinct=True),
|
||||
resources_fail=Count(
|
||||
"resources__id",
|
||||
distinct=True,
|
||||
filter=Q(status="FAIL", muted=False),
|
||||
),
|
||||
impacted_providers_str=StringAgg(
|
||||
Cast("scan__provider__provider", CharField()),
|
||||
delimiter=",",
|
||||
distinct=True,
|
||||
default="",
|
||||
),
|
||||
agg_first_seen_at=Min("first_seen_at"),
|
||||
agg_last_seen_at=Max("inserted_at"),
|
||||
agg_failing_since=Min(
|
||||
"first_seen_at", filter=Q(status="FAIL", muted=False)
|
||||
),
|
||||
check_title=Coalesce(
|
||||
Max(Cast("check_metadata__CheckTitle", CharField())),
|
||||
Max(Cast("check_metadata__checktitle", CharField())),
|
||||
Max(Cast("check_metadata__Checktitle", CharField())),
|
||||
),
|
||||
check_description=Coalesce(
|
||||
Max(Cast("check_metadata__Description", CharField())),
|
||||
Max(Cast("check_metadata__description", CharField())),
|
||||
),
|
||||
)
|
||||
|
||||
def _split_computed_aggregate_filters(
|
||||
self, params: QueryDict
|
||||
) -> tuple[QueryDict, QueryDict]:
|
||||
"""Split finding filters from computed aggregate filters."""
|
||||
computed_keys = {"status", "status__in", "severity", "severity__in"}
|
||||
finding_params = QueryDict(mutable=True)
|
||||
computed_params = QueryDict(mutable=True)
|
||||
|
||||
for key, values in params.lists():
|
||||
if key in computed_keys:
|
||||
computed_params.setlist(key, values)
|
||||
else:
|
||||
finding_params.setlist(key, values)
|
||||
|
||||
return finding_params, computed_params
|
||||
|
||||
def _get_latest_findings_per_check_provider(self, filtered_queryset):
|
||||
"""Keep all findings from the latest scan per (check_id, provider)."""
|
||||
latest_ids = (
|
||||
filtered_queryset.annotate(
|
||||
scan_rank=Window(
|
||||
expression=DenseRank(),
|
||||
partition_by=[F("check_id"), F("scan__provider_id")],
|
||||
order_by=[
|
||||
F("scan__completed_at").desc(nulls_last=True),
|
||||
F("scan_id").desc(),
|
||||
],
|
||||
)
|
||||
)
|
||||
.filter(scan_rank=1)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return filtered_queryset.filter(id__in=Subquery(latest_ids))
|
||||
|
||||
def _post_process_aggregation(self, aggregated_data):
|
||||
"""
|
||||
Post-process aggregation results to add computed fields.
|
||||
@@ -7062,13 +6976,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
severity_order, "informational"
|
||||
)
|
||||
|
||||
if "agg_first_seen_at" in row:
|
||||
row["first_seen_at"] = row.pop("agg_first_seen_at")
|
||||
if "agg_last_seen_at" in row:
|
||||
row["last_seen_at"] = row.pop("agg_last_seen_at")
|
||||
if "agg_failing_since" in row:
|
||||
row["failing_since"] = row.pop("agg_failing_since")
|
||||
|
||||
# Compute aggregated status
|
||||
if row.get("fail_count", 0) > 0:
|
||||
row["status"] = "FAIL"
|
||||
@@ -7091,7 +6998,6 @@ 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",
|
||||
@@ -7100,9 +7006,9 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"changed_count": "changed_count",
|
||||
"resources_total": "resources_total",
|
||||
"resources_fail": "resources_fail",
|
||||
"first_seen_at": "agg_first_seen_at",
|
||||
"last_seen_at": "agg_last_seen_at",
|
||||
"failing_since": "agg_failing_since",
|
||||
"first_seen_at": "first_seen_at",
|
||||
"last_seen_at": "last_seen_at",
|
||||
"failing_since": "failing_since",
|
||||
}
|
||||
|
||||
ordering = []
|
||||
@@ -7129,29 +7035,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
return ordering
|
||||
|
||||
def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDict):
|
||||
"""Apply computed filters (status/severity) on aggregated finding-group rows."""
|
||||
if not computed_params:
|
||||
return queryset
|
||||
|
||||
if computed_params.get("status") or computed_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(
|
||||
computed_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
|
||||
):
|
||||
@@ -7224,7 +7107,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
),
|
||||
first_seen_at=Min("finding__first_seen_at"),
|
||||
last_seen_at=Max("finding__inserted_at"),
|
||||
muted_reason=Max("finding__muted_reason"),
|
||||
)
|
||||
.filter(resource_id__isnull=False)
|
||||
.order_by("resource_id")
|
||||
@@ -7260,90 +7142,56 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
),
|
||||
"first_seen_at": row["first_seen_at"],
|
||||
"last_seen_at": row["last_seen_at"],
|
||||
"muted_reason": row.get("muted_reason"),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _build_aggregated_queryset(self, finding_params, latest=False):
|
||||
"""Select the summary or findings path and return an aggregated queryset."""
|
||||
finding_filterset_class = (
|
||||
LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
)
|
||||
summary_filterset_class = (
|
||||
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
|
||||
)
|
||||
|
||||
if self._requires_finding_level_aggregation(finding_params, latest=latest):
|
||||
finding_queryset = self._get_finding_queryset()
|
||||
filterset = finding_filterset_class(
|
||||
finding_params, queryset=finding_queryset
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
if latest:
|
||||
filtered_queryset = self._get_latest_findings_per_check_provider(
|
||||
filtered_queryset
|
||||
)
|
||||
return self._aggregate_findings(filtered_queryset)
|
||||
|
||||
summary_queryset = self.get_queryset()
|
||||
filterset = summary_filterset_class(finding_params, queryset=summary_queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
if latest:
|
||||
latest_per_check_ids = (
|
||||
filtered_queryset.order_by("check_id", "provider_id", "-inserted_at")
|
||||
.distinct("check_id", "provider_id")
|
||||
.values("id")
|
||||
)
|
||||
filtered_queryset = filtered_queryset.filter(
|
||||
id__in=Subquery(latest_per_check_ids)
|
||||
)
|
||||
return self._aggregate_daily_summaries(filtered_queryset)
|
||||
|
||||
def _sorted_paginated_response(self, request, aggregated_queryset):
|
||||
"""Apply ordering, pagination, post-processing, and return the Response."""
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
List finding groups with aggregation and filtering.
|
||||
|
||||
Returns findings grouped by check_id with aggregated metrics.
|
||||
Requires at least one date filter for performance.
|
||||
Uses summaries when possible and raw findings for finding-level filters.
|
||||
Uses pre-aggregated daily summaries for efficient queries.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Apply filters
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
finding_params, computed_params = self._split_computed_aggregate_filters(
|
||||
normalized_params
|
||||
)
|
||||
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=False)
|
||||
aggregated_qs = self._apply_aggregated_computed_filters(
|
||||
aggregated_qs, computed_params
|
||||
)
|
||||
return self._sorted_paginated_response(request, aggregated_qs)
|
||||
filterset = self.filterset_class(normalized_params, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
|
||||
# Re-aggregate daily summaries across the date range
|
||||
aggregated_queryset = self._aggregate_daily_summaries(filtered_queryset)
|
||||
|
||||
# Apply ordering (respect JSON:API sort param or use default)
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
# Convert JSON:API sort notation (prefix '-' for descending)
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
# Default ordering: failures first, then severity, then check_id
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
# Paginate
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
# Post-process the page
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
# Post-process all results (no pagination)
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="List latest finding groups",
|
||||
@@ -7361,22 +7209,58 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"""
|
||||
List the latest finding group state per check_id.
|
||||
|
||||
Returns findings grouped by check_id using latest data per
|
||||
(check_id, provider), without requiring date filters.
|
||||
Returns findings grouped by check_id using the latest available
|
||||
inserted_at date per check_id, without requiring date filters.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Apply other filters (provider_id, provider_type, check_id, etc.)
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
# Remove date filters since we're using latest
|
||||
for key in list(normalized_params.keys()):
|
||||
if key.startswith("inserted_at"):
|
||||
del normalized_params[key]
|
||||
|
||||
finding_params, computed_params = self._split_computed_aggregate_filters(
|
||||
normalized_params
|
||||
filterset_class = self.get_filterset_class()
|
||||
filterset = filterset_class(normalized_params, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
|
||||
# Keep only the latest row per (check_id, provider), then aggregate by check_id.
|
||||
latest_per_check_ids = (
|
||||
filtered_queryset.order_by("check_id", "provider_id", "-inserted_at")
|
||||
.distinct("check_id", "provider_id")
|
||||
.values("id")
|
||||
)
|
||||
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=True)
|
||||
aggregated_qs = self._apply_aggregated_computed_filters(
|
||||
aggregated_qs, computed_params
|
||||
latest_per_check = filtered_queryset.filter(
|
||||
id__in=Subquery(latest_per_check_ids)
|
||||
)
|
||||
return self._sorted_paginated_response(request, aggregated_qs)
|
||||
|
||||
# Re-aggregate daily summaries
|
||||
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)
|
||||
|
||||
# Apply ordering
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
# Paginate
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="List resources for a finding group",
|
||||
|
||||
+2
-1
@@ -225,7 +225,8 @@
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"user-guide/providers/kubernetes/getting-started-k8s",
|
||||
"user-guide/providers/kubernetes/misc"
|
||||
"user-guide/providers/kubernetes/misc",
|
||||
"user-guide/cookbooks/kubernetes-in-cluster"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 486 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 323 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 490 KiB |
@@ -2,13 +2,9 @@
|
||||
title: 'Google Workspace Authentication in Prowler'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login.
|
||||
|
||||
## Required Open Authorization (OAuth) Scopes
|
||||
## Required OAuth Scopes
|
||||
|
||||
Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK:
|
||||
|
||||
@@ -24,16 +20,16 @@ The delegated user must be a **super administrator** in your Google Workspace or
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed)
|
||||
### Step 1: Create a GCP Project (if needed)
|
||||
|
||||
If no GCP project exists, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
|
||||
If you don't have a GCP project, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
|
||||
|
||||
The project is only used to host the Service Account — it does not need to have any Google Workspace data in it.
|
||||
|
||||
### Step 2: Enable the Admin SDK API
|
||||
|
||||
1. Navigate to the [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Select the target project
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Select your project
|
||||
3. Navigate to **APIs & Services → Library**
|
||||
4. Search for **Admin SDK API**
|
||||
5. Click **Enable**
|
||||
@@ -52,8 +48,8 @@ The Service Account does not need any GCP IAM roles. Its access to Google Worksp
|
||||
|
||||
### Step 4: Generate a JSON Key
|
||||
|
||||
1. Click the newly created Service Account
|
||||
2. Navigate to the **Keys** tab
|
||||
1. Click on the Service Account you just created
|
||||
2. Go to the **Keys** tab
|
||||
3. Click **Add Key → Create new key**
|
||||
4. Select **JSON** format
|
||||
5. Click **Create** — the key file will download automatically
|
||||
@@ -65,7 +61,7 @@ This JSON key grants access to your Google Workspace organization. Never commit
|
||||
|
||||
### Step 5: Configure Domain-Wide Delegation in Google Workspace
|
||||
|
||||
1. Navigate to the [Google Workspace Admin Console](https://admin.google.com)
|
||||
1. Go to the [Google Workspace Admin Console](https://admin.google.com)
|
||||
2. Navigate to **Security → Access and data control → API controls**
|
||||
3. Click **Manage Domain Wide Delegation**
|
||||
4. Click **Add new**
|
||||
@@ -82,26 +78,23 @@ https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.google
|
||||
Domain-Wide Delegation must be configured by a Google Workspace **super administrator**. It may take a few minutes to propagate after saving.
|
||||
</Note>
|
||||
|
||||
### Step 6: Provide Credentials to Prowler
|
||||
### Step 6: Store Credentials Securely
|
||||
|
||||
- **Prowler Cloud:** Paste the Service Account JSON content and enter the delegated user email in the credentials form when configuring the Google Workspace provider.
|
||||
- **Prowler CLI:** Export the credentials as environment variables:
|
||||
Set your credentials as environment variables:
|
||||
|
||||
```console
|
||||
```bash
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/googleworkspace-sa.json"
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
prowler googleworkspace
|
||||
```
|
||||
|
||||
Alternatively, to pass credentials as a string (e.g., in CI/CD pipelines):
|
||||
Alternatively, if you need to pass credentials as a string (e.g., in CI/CD pipelines):
|
||||
|
||||
```console
|
||||
```bash
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT=$(cat /path/to/googleworkspace-sa.json)
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
prowler googleworkspace
|
||||
```
|
||||
|
||||
## How Prowler Resolves Credentials
|
||||
## Credential Lookup Order
|
||||
|
||||
Prowler resolves credentials in the following order:
|
||||
|
||||
@@ -154,7 +147,7 @@ The Service Account cannot impersonate the delegated user. This usually means Do
|
||||
- All three required OAuth scopes are included
|
||||
- The delegated user is a super administrator
|
||||
|
||||
### Permission Denied on Admin SDK Calls
|
||||
### Permission Denied on Admin SDK calls
|
||||
|
||||
If Prowler connects but returns empty results or permission errors for specific API calls:
|
||||
|
||||
|
||||
@@ -1,131 +1,100 @@
|
||||
---
|
||||
title: 'Getting Started With Google Workspace on Prowler'
|
||||
title: 'Getting Started with Google Workspace'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler for Google Workspace audits the organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Set up authentication for Google Workspace with the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide before starting either path:
|
||||
|
||||
- **Service Account:** Create a Service Account in a GCP project with Domain-Wide Delegation enabled.
|
||||
- **OAuth Scopes:** Authorize the required read-only OAuth scopes in the Google Workspace Admin Console.
|
||||
- **Customer ID:** Identify the Google Workspace Customer ID to use as the provider identifier.
|
||||
- **Delegated User:** Have the email of a super administrator to use as the delegated user.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
|
||||
Onboard Google Workspace using Prowler Cloud
|
||||
</Card>
|
||||
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
|
||||
Onboard Google Workspace using Prowler CLI
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prowler Cloud
|
||||
|
||||
<VersionBadge version="5.21.0" />
|
||||
|
||||
### Step 1: Locate the Customer ID
|
||||
|
||||
1. Log into the [Google Workspace Admin Console](https://admin.google.com).
|
||||
2. Navigate to "Account" > "Account Settings".
|
||||
3. Find the **Customer ID** on the Account Settings page.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
The Customer ID starts with the letter "C" followed by alphanumeric characters (e.g., `C0xxxxxxx`). This value acts as the unique identifier for the Google Workspace account in Prowler Cloud.
|
||||
</Note>
|
||||
|
||||
### Step 2: Open Prowler Cloud
|
||||
|
||||
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Navigate to "Configuration" > "Cloud Providers".
|
||||
|
||||

|
||||
|
||||
3. Click "Add Cloud Provider".
|
||||
|
||||

|
||||
|
||||
4. Select "Google Workspace".
|
||||
|
||||

|
||||
|
||||
### Step 3: Provide Credentials
|
||||
|
||||
1. Enter the **Customer ID** and an optional alias, then click "Next".
|
||||
|
||||

|
||||
|
||||
2. Paste the **Service Account JSON** credentials content.
|
||||
3. Enter the "Delegated User Email" (a super administrator in the Google Workspace organization).
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
The Service Account JSON is the full content of the key file downloaded when creating the Service Account. Paste the entire JSON object, not just the file path. For setup instructions, see the [Authentication guide](/user-guide/providers/googleworkspace/authentication).
|
||||
</Note>
|
||||
|
||||
### Step 4: Check Connection
|
||||
|
||||
1. Click "Check Connection" to verify that the credentials and Domain-Wide Delegation are configured correctly.
|
||||
2. Prowler will test the Service Account impersonation and Admin SDK access.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors.
|
||||
</Note>
|
||||
|
||||
### Step 5: Launch the Scan
|
||||
|
||||
1. Review the summary.
|
||||
2. Click "Launch Scan" to start auditing Google Workspace.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Prowler CLI
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx";
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Prowler for Google Workspace allows you to audit your organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running Prowler with the Google Workspace provider, ensure you have:
|
||||
|
||||
1. A Google Workspace account with super administrator privileges
|
||||
2. A Google Cloud Platform (GCP) project to host the Service Account
|
||||
3. Authentication configured (see [Authentication](/user-guide/providers/googleworkspace/authentication)):
|
||||
- A **Service Account JSON key** from a GCP project with Domain-Wide Delegation enabled
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Set Up Authentication
|
||||
|
||||
Set your Service Account credentials and delegated user email following the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide:
|
||||
Set your Service Account credentials file path and delegated user email as environment variables:
|
||||
|
||||
```console
|
||||
```bash
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
```
|
||||
|
||||
Alternatively, pass the credentials content directly as a JSON string:
|
||||
### Step 2: Run Prowler
|
||||
|
||||
```console
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}'
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
```
|
||||
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
Run a baseline scan after credentials are configured:
|
||||
|
||||
```console
|
||||
```bash
|
||||
prowler googleworkspace
|
||||
```
|
||||
|
||||
Prowler authenticates as the delegated user and runs all available security checks against the Google Workspace organization.
|
||||
Prowler will authenticate as the delegated user and run all available security checks against your Google Workspace organization.
|
||||
|
||||
### Step 3: Use a Custom Configuration (Optional)
|
||||
## Authentication
|
||||
|
||||
Prowler uses a **Service Account with Domain-Wide Delegation** to authenticate to Google Workspace. This requires:
|
||||
|
||||
- A Service Account created in a GCP project
|
||||
- The Admin SDK API enabled in that project
|
||||
- Domain-Wide Delegation configured in the Google Workspace Admin Console
|
||||
- A super admin user email to impersonate
|
||||
|
||||
### Using Environment Variables (Recommended)
|
||||
|
||||
```bash
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
prowler googleworkspace
|
||||
```
|
||||
|
||||
Alternatively, pass the credentials content directly as a JSON string:
|
||||
|
||||
```bash
|
||||
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}'
|
||||
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
|
||||
prowler googleworkspace
|
||||
```
|
||||
|
||||
<Note>
|
||||
The delegated user must be a super admin email in your Google Workspace organization. The service account credentials must be provided via environment variables (`GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT`).
|
||||
</Note>
|
||||
|
||||
## Understanding the Output
|
||||
|
||||
When Prowler runs successfully, it will display the credentials being used:
|
||||
|
||||
```
|
||||
Using the Google Workspace credentials below:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Google Workspace Domain: yourdomain.com │
|
||||
│ Customer ID: C0xxxxxxx │
|
||||
│ Delegated User: admin@yourdomain.com │
|
||||
│ Authentication Method: Service Account with Domain-Wide │
|
||||
│ Delegation │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Findings are reported per check. For example, the `directory_super_admin_count` check verifies the number of super administrators is within a recommended range (2–4):
|
||||
|
||||
- **PASS** — 2 to 4 super administrators found
|
||||
- **FAIL** — 0 or 1 (single point of failure) or 5+ (excessive privilege exposure)
|
||||
|
||||
Output files are saved in the configured output directory (default: `output/`) in CSV, JSON-OCSF, and HTML formats.
|
||||
|
||||
## Configuration
|
||||
|
||||
Prowler uses a configuration file to customize provider behavior. To use a custom configuration:
|
||||
|
||||
```console
|
||||
```bash
|
||||
prowler googleworkspace --config-file /path/to/config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](/user-guide/providers/googleworkspace/authentication) — Detailed guide on setting up a Service Account and Domain-Wide Delegation
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.22.0] (Prowler v5.22.0)
|
||||
## [5.21.2] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Azure MySQL flexible server checks now compare configuration values case-insensitively to avoid false negatives when Azure returns lowercase values [(#10396)](https://github.com/prowler-cloud/prowler/pull/10396)
|
||||
- Azure `vm_backup_enabled` and `vm_sufficient_daily_backup_retention_period` checks now compare VM names case-insensitively to avoid false negatives when Azure stores backup item names in a different case [(#10395)](https://github.com/prowler-cloud/prowler/pull/10395)
|
||||
- `entra_non_privileged_user_has_mfa` skips disabled users to avoid false positives [(#10426)](https://github.com/prowler-cloud/prowler/pull/10426)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1587,7 +1587,6 @@
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
@@ -1671,8 +1670,20 @@
|
||||
"budgets": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [
|
||||
@@ -3428,6 +3439,7 @@
|
||||
"datazone": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
@@ -3440,6 +3452,7 @@
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
@@ -6985,7 +6998,6 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -7010,7 +7022,6 @@
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -7684,7 +7695,6 @@
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
@@ -7922,7 +7932,6 @@
|
||||
"aws": [
|
||||
"ap-southeast-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
@@ -8246,7 +8255,6 @@
|
||||
"ap-east-1",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
@@ -8262,7 +8270,6 @@
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -8653,7 +8660,6 @@
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -9870,7 +9876,6 @@
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"il-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ class entra_non_privileged_user_has_mfa(Check):
|
||||
|
||||
for tenant_domain, users in entra_client.users.items():
|
||||
for user in users.values():
|
||||
if user.account_enabled and not is_privileged_user(
|
||||
if not is_privileged_user(
|
||||
user, entra_client.directory_roles[tenant_domain]
|
||||
):
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=user)
|
||||
|
||||
@@ -3,9 +3,7 @@ from asyncio import gather
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
||||
from msgraph import GraphServiceClient
|
||||
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
@@ -67,16 +65,9 @@ class Entra(AzureService):
|
||||
logger.info("Entra - Getting users...")
|
||||
users = {}
|
||||
try:
|
||||
request_configuration = RequestConfiguration(
|
||||
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
|
||||
select=["id", "displayName", "accountEnabled"]
|
||||
)
|
||||
)
|
||||
for tenant, client in self.clients.items():
|
||||
users.update({tenant: {}})
|
||||
users_response = await client.users.get(
|
||||
request_configuration=request_configuration
|
||||
)
|
||||
users_response = await client.users.get()
|
||||
registration_details = await self._get_user_registration_details(client)
|
||||
|
||||
try:
|
||||
@@ -90,9 +81,6 @@ class Entra(AzureService):
|
||||
is_mfa_capable=registration_details.get(
|
||||
user.id, False
|
||||
),
|
||||
account_enabled=getattr(
|
||||
user, "account_enabled", True
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -421,7 +409,6 @@ class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
is_mfa_capable: bool = False
|
||||
account_enabled: bool = True
|
||||
|
||||
|
||||
class DefaultUserRolePermissions(BaseModel):
|
||||
|
||||
@@ -132,18 +132,6 @@ Follow conventional commits:
|
||||
4. ✅ Branch is up to date with main
|
||||
5. ✅ Commits are clean and descriptive
|
||||
|
||||
## Before Re-Requesting Review (REQUIRED)
|
||||
|
||||
Resolve or respond to **every** open inline review thread before re-requesting review:
|
||||
|
||||
1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly:
|
||||
> Fixed in `abc1234`.
|
||||
2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked.
|
||||
3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open.
|
||||
4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to.
|
||||
|
||||
> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR.
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: See [references/](references/) for links to local developer guide
|
||||
|
||||
@@ -186,109 +186,6 @@ cd ui && pnpm run build
|
||||
cd ui && pnpm start
|
||||
```
|
||||
|
||||
## Batch vs Instant Component API (REQUIRED)
|
||||
|
||||
When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Independent optionals — allows invalid half-states
|
||||
interface FilterProps {
|
||||
onBatchApply?: (values: string[]) => void;
|
||||
onInstantChange?: (value: string) => void;
|
||||
isBatchMode?: boolean;
|
||||
}
|
||||
|
||||
// ✅ ALWAYS: Discriminated union — one valid shape per mode
|
||||
type BatchProps = {
|
||||
mode: "batch";
|
||||
onApply: (values: string[]) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type InstantProps = {
|
||||
mode: "instant";
|
||||
onChange: (value: string) => void;
|
||||
// onApply/onCancel are forbidden here via structural exclusion
|
||||
onApply?: never;
|
||||
onCancel?: never;
|
||||
};
|
||||
|
||||
type FilterProps = BatchProps | InstantProps;
|
||||
```
|
||||
|
||||
This makes invalid prop combinations a compile error, not a runtime surprise.
|
||||
|
||||
## Reuse Shared Display Utilities First (REQUIRED)
|
||||
|
||||
Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.
|
||||
|
||||
```typescript
|
||||
// ✅ CHECK THESE FIRST before creating a new map:
|
||||
// ui/lib/utils.ts → general formatters
|
||||
// ui/types/providers.ts → provider display names, icons
|
||||
// ui/types/findings.ts → severity/status display maps
|
||||
// ui/types/compliance.ts → category/group formatters
|
||||
|
||||
// ❌ NEVER add a local map that already exists:
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
// ...duplicating an existing shared map
|
||||
};
|
||||
|
||||
// ✅ Import and reuse instead:
|
||||
import { severityLabel } from "@/types/findings";
|
||||
```
|
||||
|
||||
If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.
|
||||
|
||||
## Derived State Rule (REQUIRED)
|
||||
|
||||
Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Mirror props into state via effect
|
||||
const [localFilter, setLocalFilter] = useState(filter);
|
||||
useEffect(() => { setLocalFilter(filter); }, [filter]);
|
||||
|
||||
// ✅ ALWAYS: Derive directly
|
||||
const localFilter = filter; // or compute inline
|
||||
```
|
||||
|
||||
If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
|
||||
|
||||
```typescript
|
||||
// Local state needed: user edits are buffered until "Apply" is clicked
|
||||
const [pending, setPending] = useState(initialValues);
|
||||
```
|
||||
|
||||
## Strict Key Typing for Label Maps (REQUIRED)
|
||||
|
||||
Avoid `Record<string, string>` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
|
||||
|
||||
```typescript
|
||||
// ❌ Loose — typos compile silently
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
actve: "Active", // typo, no error
|
||||
};
|
||||
|
||||
// ✅ Tight — union key
|
||||
type Status = "active" | "inactive" | "pending";
|
||||
const STATUS_LABELS: Record<Status, string> = {
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
pending: "Pending",
|
||||
// actve: "Active" ← compile error
|
||||
};
|
||||
|
||||
// ✅ Also fine — const satisfies
|
||||
const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
pending: "Pending",
|
||||
} as const satisfies Record<Status, string>;
|
||||
```
|
||||
|
||||
## QA Checklist Before Commit
|
||||
|
||||
- [ ] `pnpm run typecheck` passes
|
||||
@@ -302,15 +199,6 @@ const STATUS_LABELS = {
|
||||
- [ ] Accessibility: keyboard navigation, ARIA labels
|
||||
- [ ] Mobile responsive (if applicable)
|
||||
|
||||
## Pre-Re-Review Checklist (Review Thread Hygiene)
|
||||
|
||||
Before requesting re-review from a reviewer:
|
||||
|
||||
- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
|
||||
- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
|
||||
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
|
||||
- [ ] Re-request review only after all threads are in a clean state
|
||||
|
||||
## Migrations Reference
|
||||
|
||||
| From | To | Key Changes |
|
||||
|
||||
@@ -102,38 +102,6 @@ function isUser(value: unknown): value is User {
|
||||
}
|
||||
```
|
||||
|
||||
## Coupled Optional Props (REQUIRED)
|
||||
|
||||
Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.
|
||||
|
||||
```typescript
|
||||
// ❌ BEFORE: Independent optionals — half-states allowed
|
||||
interface PaginationProps {
|
||||
onPageChange?: (page: number) => void;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
// ✅ AFTER: Discriminated union — shape is all-or-nothing
|
||||
type ControlledPagination = {
|
||||
controlled: true;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
type UncontrolledPagination = {
|
||||
controlled: false;
|
||||
currentPage?: never;
|
||||
pageSize?: never;
|
||||
onPageChange?: never;
|
||||
};
|
||||
|
||||
type PaginationProps = ControlledPagination | UncontrolledPagination;
|
||||
```
|
||||
|
||||
**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.
|
||||
|
||||
## Import Types
|
||||
|
||||
```typescript
|
||||
|
||||
-80
@@ -142,86 +142,6 @@ class Test_entra_non_privileged_user_has_mfa:
|
||||
assert result[0].resource_id == user_id
|
||||
assert result[0].subscription == f"Tenant: {DOMAIN}"
|
||||
|
||||
def test_entra_disabled_user_no_privileged_no_mfa(self):
|
||||
entra_client = mock.MagicMock
|
||||
user_id = str(uuid4())
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_azure_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
|
||||
entra_non_privileged_user_has_mfa,
|
||||
)
|
||||
from prowler.providers.azure.services.entra.entra_service import (
|
||||
DirectoryRole,
|
||||
User,
|
||||
)
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
name="foo",
|
||||
is_mfa_capable=False,
|
||||
account_enabled=False,
|
||||
)
|
||||
|
||||
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
|
||||
entra_client.directory_roles = {
|
||||
DOMAIN: {
|
||||
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
|
||||
}
|
||||
}
|
||||
|
||||
check = entra_non_privileged_user_has_mfa()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_entra_disabled_user_no_privileged_mfa(self):
|
||||
entra_client = mock.MagicMock
|
||||
user_id = str(uuid4())
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_azure_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
|
||||
entra_non_privileged_user_has_mfa,
|
||||
)
|
||||
from prowler.providers.azure.services.entra.entra_service import (
|
||||
DirectoryRole,
|
||||
User,
|
||||
)
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
name="foo",
|
||||
is_mfa_capable=True,
|
||||
account_enabled=False,
|
||||
)
|
||||
|
||||
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
|
||||
entra_client.directory_roles = {
|
||||
DOMAIN: {
|
||||
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
|
||||
}
|
||||
}
|
||||
|
||||
check = entra_non_privileged_user_has_mfa()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_entra_user_privileged_no_mfa(self):
|
||||
entra_client = mock.MagicMock
|
||||
user_id = str(uuid4())
|
||||
|
||||
@@ -147,7 +147,6 @@ class Test_Entra_Service:
|
||||
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1"
|
||||
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1"
|
||||
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False
|
||||
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True
|
||||
|
||||
def test_get_authorization_policy(self):
|
||||
entra_client = Entra(set_mocked_azure_provider())
|
||||
@@ -230,8 +229,8 @@ def test_azure_entra__get_users_handles_pagination():
|
||||
entra_service = Entra.__new__(Entra)
|
||||
|
||||
users_page_one = [
|
||||
SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False),
|
||||
SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True),
|
||||
SimpleNamespace(id="user-1", display_name="User 1"),
|
||||
SimpleNamespace(id="user-2", display_name="User 2"),
|
||||
]
|
||||
users_page_two = [
|
||||
SimpleNamespace(id="user-3", display_name="User 3"),
|
||||
@@ -289,18 +288,9 @@ def test_azure_entra__get_users_handles_pagination():
|
||||
|
||||
assert len(users["tenant-1"]) == 3
|
||||
assert users_builder.get.await_count == 1
|
||||
request_configuration = users_builder.get.await_args.kwargs["request_configuration"]
|
||||
assert request_configuration.query_parameters.select == [
|
||||
"id",
|
||||
"displayName",
|
||||
"accountEnabled",
|
||||
]
|
||||
with_url_mock.assert_called_once_with("next-link")
|
||||
registration_details_builder.get.assert_awaited()
|
||||
registration_details_builder.with_url.assert_not_called()
|
||||
assert users["tenant-1"]["user-1"].is_mfa_capable is True
|
||||
assert users["tenant-1"]["user-1"].account_enabled is False
|
||||
assert users["tenant-1"]["user-2"].is_mfa_capable is True
|
||||
assert users["tenant-1"]["user-2"].account_enabled is True
|
||||
assert users["tenant-1"]["user-3"].is_mfa_capable is False
|
||||
assert users["tenant-1"]["user-3"].account_enabled is True
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.22.0] (Prowler v5.22.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection [(#10388)](https://github.com/prowler-cloud/prowler/pull/10388)
|
||||
|
||||
---
|
||||
|
||||
## [1.21.0] (Prowler v5.21.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { buildAttackPathQueries } from "./queries.adapter";
|
||||
|
||||
const presetQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "preset-query",
|
||||
attributes: {
|
||||
name: "Preset Query",
|
||||
short_description: "Returns privileged attack paths",
|
||||
description: "Returns privileged attack paths.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("buildAttackPathQueries", () => {
|
||||
it("prepends a custom query with a schema documentation link", () => {
|
||||
// Given
|
||||
const schema: AttackPathCartographySchemaAttributes = {
|
||||
id: "aws-0.129.0",
|
||||
provider: "aws",
|
||||
cartography_version: "0.129.0",
|
||||
schema_url:
|
||||
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
|
||||
raw_schema_url:
|
||||
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = buildAttackPathQueries([presetQuery], schema);
|
||||
|
||||
// Then
|
||||
expect(result[0]).toMatchObject({
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write and run your own read-only query",
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: schema.schema_url,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result[1]).toEqual(presetQuery);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,7 @@
|
||||
import { MetaDataProps } from "@/types";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
/**
|
||||
@@ -56,52 +53,3 @@ export function adaptAttackPathQueriesResponse(
|
||||
|
||||
return { data: enrichedData, metadata };
|
||||
}
|
||||
|
||||
const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
|
||||
RETURN n
|
||||
LIMIT 25`;
|
||||
|
||||
const formatSchemaDocumentationLinkText = (
|
||||
schema: AttackPathCartographySchemaAttributes,
|
||||
): string => {
|
||||
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
|
||||
};
|
||||
|
||||
const createCustomQuery = (
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery => ({
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write and run your own read-only query",
|
||||
description:
|
||||
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
|
||||
provider: "custom",
|
||||
attribution: null,
|
||||
documentation_link: schema
|
||||
? {
|
||||
text: formatSchemaDocumentationLinkText(schema),
|
||||
link: schema.schema_url,
|
||||
}
|
||||
: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
description: "",
|
||||
placeholder: CUSTOM_QUERY_PLACEHOLDER,
|
||||
required: true,
|
||||
input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const buildAttackPathQueries = (
|
||||
queries: AttackPathQuery[],
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery[] => {
|
||||
return [createCustomQuery(schema), ...queries];
|
||||
};
|
||||
|
||||
@@ -17,11 +17,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
executeCustomQuery,
|
||||
executeQuery,
|
||||
getCartographySchema,
|
||||
} from "./queries";
|
||||
import { executeQuery } from "./queries";
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
@@ -69,139 +65,3 @@ describe("executeQuery", () => {
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCustomQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "attack-paths-query-run-requests",
|
||||
id: null,
|
||||
attributes: {
|
||||
nodes: [],
|
||||
relationships: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("posts the custom query to the dedicated endpoint", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
// When
|
||||
await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"MATCH (n) RETURN n LIMIT 10",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "attack-paths-custom-query-run-requests",
|
||||
attributes: {
|
||||
query: "MATCH (n) RETURN n LIMIT 10",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty custom queries before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
" ",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Custom query cannot be empty",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects custom queries longer than 10000 characters before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"x".repeat(10001),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Custom query must be 10000 characters or fewer",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects custom queries with write operations before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"MATCH (n) SET n.name = 'updated' RETURN n",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Only read-only queries are allowed",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCartographySchema", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("fetches the schema metadata for the selected scan", async () => {
|
||||
// Given
|
||||
const apiResponse = {
|
||||
data: {
|
||||
type: "attack-paths-cartography-schemas",
|
||||
id: "aws-0.129.0",
|
||||
attributes: {
|
||||
id: "aws-0.129.0",
|
||||
provider: "aws",
|
||||
cartography_version: "0.129.0",
|
||||
schema_url:
|
||||
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
|
||||
raw_schema_url:
|
||||
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
|
||||
},
|
||||
},
|
||||
};
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue(apiResponse);
|
||||
|
||||
// When
|
||||
const result = await getCartographySchema(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(apiResponse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
AttackPathCartographySchema,
|
||||
AttackPathCartographySchemaResponse,
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathQueryResult,
|
||||
ExecuteCustomQueryRequest,
|
||||
ExecuteQueryRequest,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
@@ -106,93 +102,3 @@ export const executeQuery = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a custom openCypher query on an attack path scan
|
||||
*/
|
||||
export const executeCustomQuery = async (
|
||||
scanId: string,
|
||||
query: string,
|
||||
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
|
||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||
if (!validatedScanId.success) {
|
||||
console.error("Invalid scan ID format");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validatedQuery = customAttackPathQuerySchema.safeParse(query);
|
||||
if (!validatedQuery.success) {
|
||||
return {
|
||||
error:
|
||||
validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const requestBody: ExecuteCustomQueryRequest = {
|
||||
data: {
|
||||
type: "attack-paths-custom-query-run-requests",
|
||||
attributes: {
|
||||
query: validatedQuery.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`,
|
||||
{
|
||||
headers,
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
);
|
||||
|
||||
return (await handleApiResponse(response)) as
|
||||
| AttackPathQueryResult
|
||||
| AttackPathQueryError;
|
||||
} catch (error) {
|
||||
console.error("Error executing custom query on scan:", error);
|
||||
return {
|
||||
error:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
status: 503,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch cartography schema metadata for a specific attack path scan
|
||||
*/
|
||||
export const getCartographySchema = async (
|
||||
scanId: string,
|
||||
): Promise<{ data: AttackPathCartographySchema } | undefined> => {
|
||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||
if (!validatedScanId.success) {
|
||||
console.error("Invalid scan ID format");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`,
|
||||
{
|
||||
headers,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
const apiResponse = (await handleApiResponse(
|
||||
response,
|
||||
)) as AttackPathCartographySchemaResponse;
|
||||
|
||||
return { data: apiResponse.data };
|
||||
} catch (error) {
|
||||
console.error("Error fetching cartography schema for scan:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,11 +27,7 @@ import {
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
type ProviderProps,
|
||||
type ProviderType,
|
||||
} from "@/types/providers";
|
||||
import type { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
@@ -50,73 +46,60 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
interface AccountsSelectorProps {
|
||||
providers: ProviderProps[];
|
||||
/**
|
||||
* Currently selected provider types (from the pending ProviderTypeSelector state).
|
||||
* Used only for contextual description/empty-state messaging — does NOT narrow
|
||||
* the list of available accounts, which remains independent of provider selection.
|
||||
*/
|
||||
selectedProviderTypes?: string[];
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps {
|
||||
/**
|
||||
* Called instead of navigating immediately.
|
||||
* Use this on pages that batch filter changes (e.g. Findings).
|
||||
*
|
||||
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in"
|
||||
* @param values - The selected values array
|
||||
*/
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
/**
|
||||
* Pending selected values controlled by the parent.
|
||||
* Reflects pending state before Apply is clicked.
|
||||
*/
|
||||
selectedValues: string[];
|
||||
}
|
||||
|
||||
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||
interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps {
|
||||
onBatchChange?: never;
|
||||
selectedValues?: never;
|
||||
}
|
||||
|
||||
type AccountsSelectorProps =
|
||||
| AccountsSelectorBatchProps
|
||||
| AccountsSelectorInstantProps;
|
||||
|
||||
export function AccountsSelector({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
selectedProviderTypes,
|
||||
}: AccountsSelectorProps) {
|
||||
export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const filterKey = "filter[provider_id__in]";
|
||||
const current = searchParams.get(filterKey) || "";
|
||||
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
|
||||
const visibleProviders = providers;
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
|
||||
const selectedTypesList = selectedTypes
|
||||
? selectedTypes.split(",").filter(Boolean)
|
||||
: [];
|
||||
const selectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
const visibleProviders = providers
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
.filter((p) =>
|
||||
selectedTypesList.length > 0
|
||||
? selectedTypesList.includes(p.attributes.provider)
|
||||
: true,
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
if (onBatchChange) {
|
||||
onBatchChange("provider_id__in", ids);
|
||||
return;
|
||||
}
|
||||
navigateWithParams((params) => {
|
||||
params.delete(filterKey);
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
}
|
||||
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,12 +115,9 @@ export function AccountsSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Build a contextual description based on currently selected provider types.
|
||||
// This is purely for user guidance (aria label + empty state) and does NOT
|
||||
// narrow the list of available accounts — all providers remain selectable.
|
||||
const filterDescription =
|
||||
selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
selectedTypesList.length > 0
|
||||
? `Showing accounts for ${selectedTypesList.join(", ")} providers`
|
||||
: "All connected cloud provider accounts";
|
||||
|
||||
return (
|
||||
@@ -196,8 +176,8 @@ export function AccountsSelector({
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
{selectedTypesList.length > 0
|
||||
? "No accounts available for selected providers"
|
||||
: "No connected accounts available"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -152,60 +152,22 @@ const PROVIDER_DATA: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
type ProviderTypeSelectorProps = {
|
||||
providers: ProviderProps[];
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
interface ProviderTypeSelectorBatchProps extends ProviderTypeSelectorBaseProps {
|
||||
/**
|
||||
* Called instead of navigating immediately.
|
||||
* Use this on pages that batch filter changes (e.g. Findings).
|
||||
*
|
||||
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_type__in"
|
||||
* @param values - The selected values array
|
||||
*/
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
/**
|
||||
* Pending selected values controlled by the parent.
|
||||
* Reflects pending state before Apply is clicked.
|
||||
*/
|
||||
selectedValues: string[];
|
||||
}
|
||||
|
||||
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||
interface ProviderTypeSelectorInstantProps
|
||||
extends ProviderTypeSelectorBaseProps {
|
||||
onBatchChange?: never;
|
||||
selectedValues?: never;
|
||||
}
|
||||
|
||||
type ProviderTypeSelectorProps =
|
||||
| ProviderTypeSelectorBatchProps
|
||||
| ProviderTypeSelectorInstantProps;
|
||||
};
|
||||
|
||||
export const ProviderTypeSelector = ({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
|
||||
const urlSelectedTypes = currentProviders
|
||||
const selectedTypes = currentProviders
|
||||
? currentProviders.split(",").filter(Boolean)
|
||||
: [];
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedTypes = onBatchChange ? selectedValues : urlSelectedTypes;
|
||||
|
||||
const handleMultiValueChange = (values: string[]) => {
|
||||
if (onBatchChange) {
|
||||
onBatchChange("provider_type__in", values);
|
||||
return;
|
||||
}
|
||||
navigateWithParams((params) => {
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
@@ -213,6 +175,10 @@ export const ProviderTypeSelector = ({
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export { ExecuteButton } from "./execute-button";
|
||||
export * from "./graph";
|
||||
export * from "./node-detail";
|
||||
export { QueryDescription } from "./query-description";
|
||||
export { QueryExecutionError } from "./query-execution-error";
|
||||
export { QueryParametersForm } from "./query-parameters-form";
|
||||
export { QuerySelector } from "./query-selector";
|
||||
export { ScanListTable } from "./scan-list-table";
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
import { QueryDescription } from "./query-description";
|
||||
|
||||
const customQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "custom-query",
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description:
|
||||
"Run a read-only openCypher query against the selected Attack Paths scan.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: "https://example.com/schema",
|
||||
},
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("QueryDescription", () => {
|
||||
it("renders the schema documentation link inside an info alert", () => {
|
||||
// Given
|
||||
render(<QueryDescription query={customQuery} />);
|
||||
|
||||
// When
|
||||
const alert = screen.getByRole("alert");
|
||||
const link = screen.getByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://example.com/schema");
|
||||
});
|
||||
|
||||
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
|
||||
// Given
|
||||
const queryWithUnsafeLinks: AttackPathQuery = {
|
||||
...customQuery,
|
||||
attributes: {
|
||||
...customQuery.attributes,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
attribution: {
|
||||
text: "Unsafe source",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
render(<QueryDescription query={queryWithUnsafeLinks} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /unsafe source/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/cartography schema used by prowler for aws graphs/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
interface QueryDescriptionProps {
|
||||
query: AttackPathQuery;
|
||||
}
|
||||
|
||||
const isSafeUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||
const documentationLink = query.attributes.documentation_link;
|
||||
const attribution = query.attributes.attribution;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<Info className="text-bg-data-info mt-0.5 size-4 shrink-0" />
|
||||
<AlertDescription className="w-full gap-2">
|
||||
<p className="whitespace-pre-line">{query.attributes.description}</p>
|
||||
|
||||
{documentationLink && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(documentationLink.link) ? (
|
||||
<a
|
||||
href={documentationLink.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
{documentationLink.text}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium">{documentationLink.text}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{attribution && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(attribution.link) ? (
|
||||
<>
|
||||
Source:{" "}
|
||||
<a
|
||||
href={attribution.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{attribution.text}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Source: <span>{attribution.text}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { QueryExecutionError } from "./query-execution-error";
|
||||
|
||||
describe("QueryExecutionError", () => {
|
||||
it("renders the default title and the raw query error details without extra copy", () => {
|
||||
// Given
|
||||
const error =
|
||||
"Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)";
|
||||
|
||||
// When
|
||||
render(<QueryExecutionError error={error} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/query execution failed/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/the attack paths query could not be executed/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom title and description when provided", () => {
|
||||
// Given
|
||||
const error = "Failed to load available queries";
|
||||
|
||||
// When
|
||||
render(
|
||||
<QueryExecutionError
|
||||
title="Failed to load queries"
|
||||
description="Available Attack Paths queries could not be loaded for this scan."
|
||||
error={error}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/available attack paths queries could not be loaded for this scan/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { CircleAlert } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
|
||||
|
||||
interface QueryExecutionErrorProps {
|
||||
error: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const QueryExecutionError = ({
|
||||
error,
|
||||
title = "Query execution failed",
|
||||
description,
|
||||
}: QueryExecutionErrorProps) => {
|
||||
return (
|
||||
<Alert variant="error">
|
||||
<CircleAlert className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription className="w-full gap-3">
|
||||
{description ? <p>{description}</p> : null}
|
||||
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
|
||||
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+1
-121
@@ -1,12 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
import { QueryParametersForm } from "./query-parameters-form";
|
||||
|
||||
@@ -46,64 +42,6 @@ function TestForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function TestCustomQueryForm() {
|
||||
const customQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description: "Run a custom query against the graph.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
input_type: "textarea",
|
||||
placeholder: "MATCH (n) RETURN n LIMIT 25",
|
||||
description: "",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
query: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<QueryParametersForm selectedQuery={customQuery} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TestFormWithError() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
tag_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setError("tag_key", {
|
||||
type: "manual",
|
||||
message: "Tag key is required",
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<QueryParametersForm selectedQuery={mockQuery} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QueryParametersForm", () => {
|
||||
it("uses the field description as the placeholder instead of rendering helper text below", () => {
|
||||
// Given
|
||||
@@ -132,62 +70,4 @@ describe("QueryParametersForm", () => {
|
||||
screen.queryByText("Tag key to filter the S3 bucket."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a textarea when the parameter input type is textarea", () => {
|
||||
// Given
|
||||
render(<TestCustomQueryForm />);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", { name: /opencypher/i });
|
||||
const codeEditor = screen.getByTestId("query-code-editor");
|
||||
|
||||
// Then
|
||||
expect(input.tagName).toBe("TEXTAREA");
|
||||
expect(input).toHaveAttribute("data-slot", "textarea");
|
||||
expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25");
|
||||
expect(input).toHaveAttribute("spellcheck", "false");
|
||||
expect(input).toHaveAttribute("autocomplete", "off");
|
||||
expect(input).toHaveAttribute("autocorrect", "off");
|
||||
expect(input).toHaveAttribute("autocapitalize", "none");
|
||||
expect(input).toHaveClass(
|
||||
"minimal-scrollbar",
|
||||
"min-h-[320px]",
|
||||
"font-mono",
|
||||
"leading-6",
|
||||
);
|
||||
expect(codeEditor).toHaveClass(
|
||||
"rounded-xl",
|
||||
"border",
|
||||
"bg-bg-neutral-primary",
|
||||
);
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the design-system error token for field validation messages", async () => {
|
||||
// Given
|
||||
render(<TestFormWithError />);
|
||||
|
||||
// When
|
||||
const errorMessage = await screen.findByText("Tag key is required");
|
||||
|
||||
// Then
|
||||
expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs");
|
||||
});
|
||||
|
||||
it("connects field errors to the input for accessibility", async () => {
|
||||
// Given
|
||||
render(<TestFormWithError />);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", { name: /tag key/i });
|
||||
const errorMessage = await screen.findByText("Tag key is required");
|
||||
|
||||
// Then
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(errorMessage).toHaveAttribute("id");
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
expect.stringContaining(errorMessage.getAttribute("id") ?? ""),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+50
-116
@@ -1,21 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { Input, Textarea } from "@/components/shadcn";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
import { Input } from "@/components/shadcn";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
interface QueryParametersFormProps {
|
||||
selectedQuery: AttackPathQuery | null | undefined;
|
||||
@@ -28,7 +16,10 @@ interface QueryParametersFormProps {
|
||||
export const QueryParametersForm = ({
|
||||
selectedQuery,
|
||||
}: QueryParametersFormProps) => {
|
||||
const { control } = useFormContext();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
|
||||
return null;
|
||||
@@ -45,26 +36,23 @@ export const QueryParametersForm = ({
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
{selectedQuery.attributes.parameters.map((param) => (
|
||||
<FormField
|
||||
<Controller
|
||||
key={param.name}
|
||||
name={param.name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => {
|
||||
render={({ field }) => {
|
||||
if (param.data_type === "boolean") {
|
||||
return (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
field.value === true || field.value === "true"
|
||||
}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
aria-label={param.label}
|
||||
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
|
||||
/>
|
||||
</FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={param.name}
|
||||
checked={field.value === true || field.value === "true"}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
aria-label={param.label}
|
||||
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{param.label}
|
||||
@@ -76,98 +64,44 @@ export const QueryParametersForm = ({
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder =
|
||||
param.description ||
|
||||
param.placeholder ||
|
||||
`Enter ${param.label.toLowerCase()}`;
|
||||
|
||||
const isTextarea =
|
||||
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
|
||||
const isCustomCodeEditor =
|
||||
selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
param.name === "query" &&
|
||||
isTextarea;
|
||||
const errorMessage = (() => {
|
||||
const error = errors[param.name];
|
||||
if (error && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
return (
|
||||
<FormItem
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
isTextarea && "md:col-span-2",
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor={param.name}
|
||||
className="text-text-neutral-tertiary text-xs font-medium"
|
||||
>
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={param.name}
|
||||
type={param.data_type === "number" ? "number" : "text"}
|
||||
placeholder={
|
||||
param.description ||
|
||||
param.placeholder ||
|
||||
`Enter ${param.label.toLowerCase()}`
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<span className="text-xs text-red-500">{errorMessage}</span>
|
||||
)}
|
||||
>
|
||||
{!isCustomCodeEditor && (
|
||||
<FormLabel className="text-text-neutral-tertiary text-xs font-medium">
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{isCustomCodeEditor ? (
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
|
||||
fieldState.invalid && "border-border-error-primary",
|
||||
)}
|
||||
>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium">
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-[11px]">
|
||||
Read-only
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
aria-label={param.label}
|
||||
variant="ghost"
|
||||
textareaSize="lg"
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
className="minimal-scrollbar min-h-[320px] rounded-none border-0 bg-transparent font-mono text-xs leading-6 hover:bg-transparent focus:bg-transparent focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
) : (
|
||||
<FormControl>
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
{...field}
|
||||
textareaSize="lg"
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...field}
|
||||
type={
|
||||
param.data_type === "number" ? "number" : "text"
|
||||
}
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
-123
@@ -1,12 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
} from "@/lib/attack-paths/custom-query";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
|
||||
|
||||
import { useQueryBuilder } from "./use-query-builder";
|
||||
|
||||
@@ -43,27 +38,6 @@ const mockQueries: AttackPathQuery[] = [
|
||||
parameters: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description: "Run a custom query against the graph.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
description: "",
|
||||
required: true,
|
||||
input_type: "textarea",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("useQueryBuilder", () => {
|
||||
@@ -103,101 +77,4 @@ describe("useQueryBuilder", () => {
|
||||
);
|
||||
expect(result.current.getQueryParameters()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects whitespace-only custom queries before execution", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("query", " ");
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
"Custom query cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects custom queries longer than the supported limit", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue(
|
||||
"query",
|
||||
"x".repeat(CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH + 1),
|
||||
);
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects custom queries containing write operations", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("query", "CREATE (n:Test) RETURN n");
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
);
|
||||
expect(result.current.isExecutionBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,28 +5,15 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
customAttackPathQuerySchema,
|
||||
} from "@/lib/attack-paths/custom-query";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
const getValidationSchema = (query?: AttackPathQuery) => {
|
||||
const schemaObject: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
query?.attributes.parameters.forEach((param) => {
|
||||
const isCustomQueryParameter =
|
||||
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
param.name === "query" &&
|
||||
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
|
||||
|
||||
let fieldSchema: z.ZodTypeAny = isCustomQueryParameter
|
||||
? customAttackPathQuerySchema
|
||||
: z.string().min(1, `${param.label} is required`);
|
||||
let fieldSchema: z.ZodTypeAny = z
|
||||
.string()
|
||||
.min(1, `${param.label} is required`);
|
||||
|
||||
if (param.data_type === "number") {
|
||||
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
|
||||
@@ -106,11 +93,6 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
return form.formState.isValid;
|
||||
};
|
||||
|
||||
const isExecutionBlocked =
|
||||
selectedQueryData?.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
form.formState.errors.query?.message ===
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE;
|
||||
|
||||
return {
|
||||
selectedQuery,
|
||||
selectedQueryData,
|
||||
@@ -119,6 +101,5 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
handleQueryChange,
|
||||
getQueryParameters,
|
||||
isFormValid,
|
||||
isExecutionBlocked,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,12 +7,9 @@ import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import {
|
||||
buildAttackPathQueries,
|
||||
executeCustomQuery,
|
||||
executeQuery,
|
||||
getAttackPathScans,
|
||||
getAvailableQueries,
|
||||
getCartographySchema,
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
@@ -38,7 +35,6 @@ import type {
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
AttackPathGraph,
|
||||
@@ -47,8 +43,6 @@ import {
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
QueryDescription,
|
||||
QueryExecutionError,
|
||||
QueryParametersForm,
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
@@ -144,21 +138,11 @@ export default function AttackPathsPage() {
|
||||
|
||||
setQueriesLoading(true);
|
||||
try {
|
||||
const [queriesData, schemaData] = await Promise.all([
|
||||
getAvailableQueries(scanId),
|
||||
getCartographySchema(scanId),
|
||||
]);
|
||||
|
||||
const availableQueries = buildAttackPathQueries(
|
||||
queriesData?.data ?? [],
|
||||
schemaData?.data.attributes,
|
||||
);
|
||||
|
||||
if (availableQueries.length > 0) {
|
||||
setQueries(availableQueries);
|
||||
const queriesData = await getAvailableQueries(scanId);
|
||||
if (queriesData?.data) {
|
||||
setQueries(queriesData.data);
|
||||
setQueriesError(null);
|
||||
} else {
|
||||
setQueries([]);
|
||||
setQueriesError("Failed to load available queries");
|
||||
toast({
|
||||
title: "Error",
|
||||
@@ -215,12 +199,15 @@ export default function AttackPathsPage() {
|
||||
graphState.setError(null);
|
||||
|
||||
try {
|
||||
const parameters = queryBuilder.getQueryParameters();
|
||||
const isCustomQuery =
|
||||
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||
const result = isCustomQuery
|
||||
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
|
||||
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
|
||||
const parameters = queryBuilder.getQueryParameters() as Record<
|
||||
string,
|
||||
string | number | boolean
|
||||
>;
|
||||
const result = await executeQuery(
|
||||
scanId,
|
||||
queryBuilder.selectedQuery,
|
||||
parameters,
|
||||
);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as AttackPathQueryError;
|
||||
@@ -384,10 +371,9 @@ export default function AttackPathsPage() {
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
<QueryExecutionError
|
||||
title="Failed to load queries"
|
||||
error={queriesError}
|
||||
/>
|
||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||
{queriesError}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<FormProvider {...queryBuilder.form}>
|
||||
@@ -398,9 +384,40 @@ export default function AttackPathsPage() {
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
<QueryDescription
|
||||
query={queryBuilder.selectedQueryData}
|
||||
/>
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
style={{ color: "var(--bg-data-info)" }}
|
||||
/>
|
||||
<p className="whitespace-pre-line">
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{queryBuilder.selectedQueryData.attributes
|
||||
.attribution && (
|
||||
<p className="mt-2 text-xs">
|
||||
Source:{" "}
|
||||
<a
|
||||
href={
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.link
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.text
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryBuilder.selectedQuery && (
|
||||
@@ -413,16 +430,15 @@ export default function AttackPathsPage() {
|
||||
<div className="flex justify-end gap-3">
|
||||
<ExecuteButton
|
||||
isLoading={graphState.loading}
|
||||
isDisabled={
|
||||
!queryBuilder.selectedQuery ||
|
||||
queryBuilder.isExecutionBlocked
|
||||
}
|
||||
isDisabled={!queryBuilder.selectedQuery}
|
||||
onExecute={handleExecuteQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{graphState.error && (
|
||||
<QueryExecutionError error={graphState.error} />
|
||||
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
||||
{graphState.error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
extractSortAndKey,
|
||||
hasDateOrScanFilter,
|
||||
} from "@/lib";
|
||||
import {
|
||||
createProviderDetailsMappingById,
|
||||
extractProviderIds,
|
||||
} from "@/lib/provider-helpers";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
import { FindingProps, SearchParamsProps } from "@/types/components";
|
||||
|
||||
@@ -120,6 +124,12 @@ export default async function Findings({
|
||||
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
|
||||
// Extract provider IDs and details using helper functions
|
||||
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
||||
const providerDetails = providersData
|
||||
? createProviderDetailsMappingById(providerIds, providersData)
|
||||
: [];
|
||||
|
||||
// Extract scan UUIDs with "completed" state and more than one resource
|
||||
const completedScans = scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
@@ -141,6 +151,9 @@ export default async function Findings({
|
||||
<div className="mb-6">
|
||||
<FindingsFilters
|
||||
providers={providersData?.data || []}
|
||||
providerIds={providerIds}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans || []}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in jsdom
|
||||
vi.mock("lucide-react", () => ({
|
||||
Check: () => <svg data-testid="check-icon" />,
|
||||
X: () => <svg data-testid="x-icon" />,
|
||||
}));
|
||||
|
||||
// Mock @/components/shadcn to avoid next-auth import chain
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
"aria-label": ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
"aria-label"?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...classes: (string | undefined | false)[]) =>
|
||||
classes.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
|
||||
// ── Future E2E coverage ────────────────────────────────────────────────────
|
||||
// TODO (E2E): Full apply-filters button flow should be covered in Playwright tests:
|
||||
// - Button appears disabled when no filters have been staged
|
||||
// - Button shows correct count after staging multiple filters
|
||||
// - Clicking Apply pushes all pending filters to the URL in one navigation event
|
||||
// - Clicking Discard resets pending state to current URL state (staged filters disappear)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ApplyFiltersButton", () => {
|
||||
// ── No changes ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("when hasChanges is false", () => {
|
||||
it("should render the Apply Filters button as disabled", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={false}
|
||||
changeCount={0}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const applyButton = screen.getByRole("button", {
|
||||
name: "Apply Filters",
|
||||
});
|
||||
expect(applyButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should NOT render the discard (X) button when there are no changes", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={false}
|
||||
changeCount={0}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: /discard/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Apply Filters' label without count", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={false}
|
||||
changeCount={0}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply Filters" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Has changes ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("when hasChanges is true", () => {
|
||||
it("should render the Apply Filters button as enabled", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={2}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const applyButton = screen.getByRole("button", {
|
||||
name: "Apply Filters (2)",
|
||||
});
|
||||
expect(applyButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show the change count in the button label", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={3}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply Filters (3)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
|
||||
// Given — hasChanges=true but changeCount=0 (edge case)
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={0}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply Filters" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the discard (X) button", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={1}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: /discard pending filter changes/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── onApply interaction ──────────────────────────────────────────────────
|
||||
|
||||
describe("onApply", () => {
|
||||
it("should call onApply when the Apply Filters button is clicked", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
const onDiscard = vi.fn();
|
||||
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={1}
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Apply Filters (1)" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call onApply when the button is disabled", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={false}
|
||||
changeCount={0}
|
||||
onApply={onApply}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
|
||||
|
||||
// Then — disabled button should not fire
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── onDiscard interaction ────────────────────────────────────────────────
|
||||
|
||||
describe("onDiscard", () => {
|
||||
it("should call onDiscard when the Discard button is clicked", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
const onDiscard = vi.fn();
|
||||
|
||||
render(
|
||||
<ApplyFiltersButton
|
||||
hasChanges={true}
|
||||
changeCount={2}
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /discard pending filter changes/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ApplyFiltersButtonProps {
|
||||
/** Whether there are pending changes that differ from the applied (URL) state */
|
||||
hasChanges: boolean;
|
||||
/** Number of filter keys that have pending changes */
|
||||
changeCount: number;
|
||||
/** Called when the user clicks "Apply Filters" */
|
||||
onApply: () => void;
|
||||
/** Called when the user clicks the discard (X) action */
|
||||
onDiscard: () => void;
|
||||
/** Optional extra class names for the outer wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an "Apply Filters" button with an optional discard action.
|
||||
*
|
||||
* - Shows the count of pending changes when `hasChanges` is true.
|
||||
* - The apply button is disabled (and visually muted) when there are no changes.
|
||||
* - The discard (X) button only appears when there are pending changes.
|
||||
* - Uses Prowler's shadcn `Button` component.
|
||||
*/
|
||||
export const ApplyFiltersButton = ({
|
||||
hasChanges,
|
||||
changeCount,
|
||||
onApply,
|
||||
onDiscard,
|
||||
className,
|
||||
}: ApplyFiltersButtonProps) => {
|
||||
const label =
|
||||
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!hasChanges}
|
||||
onClick={onApply}
|
||||
aria-label={label}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDiscard}
|
||||
aria-label="Discard pending filter changes"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,19 +17,6 @@ export interface ClearFiltersButtonProps {
|
||||
showCount?: boolean;
|
||||
/** Use link style (text only, no button background) */
|
||||
variant?: "link" | "default";
|
||||
/**
|
||||
* Optional callback for batch mode. When provided, this is called INSTEAD
|
||||
* of pushing URL params directly. Useful for clearing pending filter state
|
||||
* without immediately navigating.
|
||||
*/
|
||||
onClear?: () => void;
|
||||
/**
|
||||
* In batch mode, the number of pending filter keys that have non-empty values.
|
||||
* When provided alongside `onClear`, overrides the URL-based count shown by
|
||||
* `showCount`. This ensures the displayed count reflects the pending state
|
||||
* (not the last-applied URL state) while the user is editing filters.
|
||||
*/
|
||||
pendingCount?: number;
|
||||
}
|
||||
|
||||
export const ClearFiltersButton = ({
|
||||
@@ -37,8 +24,6 @@ export const ClearFiltersButton = ({
|
||||
ariaLabel = "Reset",
|
||||
showCount = false,
|
||||
variant = "link",
|
||||
onClear,
|
||||
pendingCount,
|
||||
}: ClearFiltersButtonProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -66,27 +51,17 @@ export const ClearFiltersButton = ({
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}, [router, searchParams, pathname]);
|
||||
|
||||
// In batch mode: use pendingCount if provided; otherwise fall back to URL count.
|
||||
// In instant mode: always use URL count.
|
||||
const displayCount =
|
||||
onClear && pendingCount !== undefined ? pendingCount : filterCount;
|
||||
|
||||
// In instant mode: hide when no URL filters exist
|
||||
if (!onClear && filterCount === 0) {
|
||||
// Only show button if there are filters other than the excluded ones
|
||||
if (filterCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In batch mode: hide when there are no pending or URL filters to clear
|
||||
if (onClear && displayCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayText = showCount ? `Clear Filters (${displayCount})` : text;
|
||||
const displayText = showCount ? `Clear Filters (${filterCount})` : text;
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClear ?? clearFiltersPreservingExcluded}
|
||||
onClick={clearFiltersPreservingExcluded}
|
||||
variant={variant}
|
||||
>
|
||||
<XCircle className="mr-0.5 size-4" />
|
||||
|
||||
@@ -11,35 +11,7 @@ const MUTED_FILTER_VALUES = {
|
||||
INCLUDE: "include",
|
||||
} as const;
|
||||
|
||||
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
|
||||
interface CustomCheckboxMutedFindingsBatchProps {
|
||||
/**
|
||||
* Called instead of navigating directly.
|
||||
* Receives the filter key ("muted") and the string value ("include" or "false").
|
||||
*/
|
||||
onBatchChange: (filterKey: string, value: string) => void;
|
||||
/**
|
||||
* Controlled checked state from the parent (pending state).
|
||||
* `true` = include muted, `false` = exclude muted.
|
||||
* `undefined` defers to URL state while pending state is not yet set.
|
||||
*/
|
||||
checked: boolean | undefined;
|
||||
}
|
||||
|
||||
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||
interface CustomCheckboxMutedFindingsInstantProps {
|
||||
onBatchChange?: never;
|
||||
checked?: never;
|
||||
}
|
||||
|
||||
type CustomCheckboxMutedFindingsProps =
|
||||
| CustomCheckboxMutedFindingsBatchProps
|
||||
| CustomCheckboxMutedFindingsInstantProps;
|
||||
|
||||
export const CustomCheckboxMutedFindings = ({
|
||||
onBatchChange,
|
||||
checked: checkedProp,
|
||||
}: CustomCheckboxMutedFindingsProps = {}) => {
|
||||
export const CustomCheckboxMutedFindings = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
@@ -49,25 +21,11 @@ export const CustomCheckboxMutedFindings = ({
|
||||
// URL states:
|
||||
// - filter[muted]=false → Exclude muted (checkbox UNCHECKED)
|
||||
// - filter[muted]=include → Include muted (checkbox CHECKED)
|
||||
// When a controlled `checked` prop is provided (batch mode), use it; otherwise fall back to URL.
|
||||
const includeMuted =
|
||||
checkedProp !== undefined
|
||||
? checkedProp
|
||||
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
|
||||
const includeMuted = mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
|
||||
|
||||
const handleMutedChange = (checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true;
|
||||
|
||||
if (onBatchChange) {
|
||||
// Batch mode: notify caller instead of navigating
|
||||
onBatchChange(
|
||||
"muted",
|
||||
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mode (default): navigate immediately
|
||||
navigateWithParams((params) => {
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Calendar } from "@/components/shadcn/calendar";
|
||||
import {
|
||||
@@ -14,67 +14,22 @@ import {
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Batch mode: caller controls both the pending date value and the notification callback (all-or-nothing). */
|
||||
interface CustomDatePickerBatchProps {
|
||||
/**
|
||||
* Called instead of updating the URL directly.
|
||||
* Receives the filter key ("inserted_at") and the formatted date string (YYYY-MM-DD).
|
||||
*/
|
||||
onBatchChange: (filterKey: string, value: string) => void;
|
||||
/**
|
||||
* Controlled date value from the parent (pending state).
|
||||
* Expected format: YYYY-MM-DD (or any value parseable by `new Date()`).
|
||||
*/
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||
interface CustomDatePickerInstantProps {
|
||||
onBatchChange?: never;
|
||||
value?: never;
|
||||
}
|
||||
|
||||
type CustomDatePickerProps =
|
||||
| CustomDatePickerBatchProps
|
||||
| CustomDatePickerInstantProps;
|
||||
|
||||
const parseDate = (raw: string | null | undefined): Date | undefined => {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
// Use T00:00:00 suffix to avoid timezone offset shifting the date
|
||||
return new Date(raw + "T00:00:00");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomDatePicker = ({
|
||||
onBatchChange,
|
||||
value: valueProp,
|
||||
}: CustomDatePickerProps = {}) => {
|
||||
export const CustomDatePicker = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Derive the displayed date directly from the controlled source of truth:
|
||||
// - Batch mode: `valueProp` from parent (pending state)
|
||||
// - Instant mode: `searchParams` from URL (re-renders automatically on URL change)
|
||||
const date =
|
||||
valueProp !== undefined
|
||||
? parseDate(valueProp)
|
||||
: parseDate(searchParams.get("filter[inserted_at]"));
|
||||
const [date, setDate] = useState<Date | undefined>(() => {
|
||||
const dateParam = searchParams.get("filter[inserted_at]");
|
||||
if (!dateParam) return undefined;
|
||||
try {
|
||||
return new Date(dateParam);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const applyDateFilter = (selectedDate: Date | undefined) => {
|
||||
if (onBatchChange) {
|
||||
// Batch mode: notify caller instead of updating URL
|
||||
onBatchChange(
|
||||
"inserted_at",
|
||||
selectedDate ? format(selectedDate, "yyyy-MM-dd") : "",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mode (default): push to URL immediately
|
||||
if (selectedDate) {
|
||||
// Format as YYYY-MM-DD for the API
|
||||
updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd"));
|
||||
@@ -83,7 +38,22 @@ export const CustomDatePicker = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Sync local state with URL params (e.g., when Clear Filters is clicked)
|
||||
useEffect(() => {
|
||||
const dateParam = searchParams.get("filter[inserted_at]");
|
||||
if (!dateParam) {
|
||||
setDate(undefined);
|
||||
} else {
|
||||
try {
|
||||
setDate(new Date(dateParam));
|
||||
} catch {
|
||||
setDate(undefined);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleDateSelect = (newDate: Date | undefined) => {
|
||||
setDate(newDate);
|
||||
applyDateFilter(newDate);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in jsdom
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <svg data-testid="x-icon" />,
|
||||
}));
|
||||
|
||||
// Mock @/components/shadcn to avoid next-auth import chain
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<span data-testid="badge" data-variant={variant} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...classes: (string | undefined | false)[]) =>
|
||||
classes.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
|
||||
// ── Future E2E coverage ────────────────────────────────────────────────────
|
||||
// TODO (E2E): Full filter strip flow should be covered in Playwright tests:
|
||||
// - Filter chips appear after staging selections in the findings page
|
||||
// - Removing a chip via the X button un-stages that filter value
|
||||
// - "Clear all" removes all staged filter chips at once
|
||||
// - Chips disappear after applying filters (pending state resets to URL state)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockChips: FilterChip[] = [
|
||||
{ key: "filter[severity__in]", label: "Severity", value: "critical" },
|
||||
{ key: "filter[severity__in]", label: "Severity", value: "high" },
|
||||
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
|
||||
];
|
||||
|
||||
describe("FilterSummaryStrip", () => {
|
||||
// ── Empty state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("when chips array is empty", () => {
|
||||
it("should not render anything", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
const { container } = render(
|
||||
<FilterSummaryStrip
|
||||
chips={[]}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chip rendering ───────────────────────────────────────────────────────
|
||||
|
||||
describe("when chips are provided", () => {
|
||||
it("should render a chip for each filter value", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={mockChips}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — 3 chips should be visible (2 severity + 1 status)
|
||||
expect(screen.getAllByTestId("badge")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should display the label and value text for each chip", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={[
|
||||
{
|
||||
key: "filter[severity__in]",
|
||||
label: "Severity",
|
||||
value: "critical",
|
||||
},
|
||||
]}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Severity:")).toBeInTheDocument();
|
||||
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display displayValue when provided instead of value", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={[
|
||||
{
|
||||
key: "filter[status__in]",
|
||||
label: "Status",
|
||||
value: "FAIL",
|
||||
displayValue: "Failed",
|
||||
},
|
||||
]}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — displayValue takes precedence
|
||||
expect(screen.getByText("Failed")).toBeInTheDocument();
|
||||
expect(screen.queryByText("FAIL")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a 'Clear all' button", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={mockChips}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear all" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an aria-label region for accessibility", () => {
|
||||
// Given
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={mockChips}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Active filters" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── onRemove interaction ─────────────────────────────────────────────────
|
||||
|
||||
describe("onRemove", () => {
|
||||
it("should call onRemove with correct filterKey and value when X is clicked", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={[
|
||||
{
|
||||
key: "filter[severity__in]",
|
||||
label: "Severity",
|
||||
value: "critical",
|
||||
},
|
||||
]}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const removeButton = screen.getByRole("button", {
|
||||
name: /Remove Severity filter: critical/i,
|
||||
});
|
||||
await user.click(removeButton);
|
||||
|
||||
// Then
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "critical");
|
||||
});
|
||||
|
||||
it("should call onRemove with the correct chip when there are multiple chips", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={mockChips}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — click the X button for "high" severity
|
||||
const removeHighButton = screen.getByRole("button", {
|
||||
name: /Remove Severity filter: high/i,
|
||||
});
|
||||
await user.click(removeHighButton);
|
||||
|
||||
// Then
|
||||
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "high");
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── onClearAll interaction ───────────────────────────────────────────────
|
||||
|
||||
describe("onClearAll", () => {
|
||||
it("should call onClearAll when 'Clear all' is clicked", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
const onClearAll = vi.fn();
|
||||
|
||||
render(
|
||||
<FilterSummaryStrip
|
||||
chips={mockChips}
|
||||
onRemove={onRemove}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
|
||||
// Then
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FilterChip {
|
||||
/** The filter parameter key, e.g. "filter[severity__in]" */
|
||||
key: string;
|
||||
/** Human-readable label, e.g. "Severity" */
|
||||
label: string;
|
||||
/** The individual value within the filter, e.g. "critical" */
|
||||
value: string;
|
||||
/** Optional display text for the value (defaults to `value`) */
|
||||
displayValue?: string;
|
||||
}
|
||||
|
||||
export interface FilterSummaryStripProps {
|
||||
/** List of individual chips to render */
|
||||
chips: FilterChip[];
|
||||
/** Called when the user clicks the X on a chip */
|
||||
onRemove: (key: string, value: string) => void;
|
||||
/** Called when the user clicks "Clear all" */
|
||||
onClearAll: () => void;
|
||||
/** Optional extra class names for the outer wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a horizontal strip of removable filter chips summarising
|
||||
* the current pending filter state.
|
||||
*
|
||||
* - Hidden when `chips` is empty.
|
||||
* - Each chip carries its own X button to remove that single value.
|
||||
* - A "Clear all" link removes everything at once.
|
||||
* - Reusable: no Findings-specific logic, driven entirely by props.
|
||||
*/
|
||||
export const FilterSummaryStrip = ({
|
||||
chips,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
className,
|
||||
}: FilterSummaryStripProps) => {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2 py-2", className)}
|
||||
role="region"
|
||||
aria-label="Active filters"
|
||||
aria-live="polite"
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<Badge
|
||||
key={`${chip.key}-${chip.value}`}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 pr-1"
|
||||
>
|
||||
<span className="text-text-neutral-primary text-xs">
|
||||
<span className="font-medium">{chip.label}:</span>{" "}
|
||||
{chip.displayValue ?? chip.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${chip.label} filter: ${chip.displayValue ?? chip.value}`}
|
||||
onClick={() => onRemove(chip.key, chip.value)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary ml-0.5 rounded-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary text-xs underline-offset-2 hover:underline focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-none"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
export * from "./apply-filters-button";
|
||||
export * from "./clear-filters-button";
|
||||
export * from "./custom-checkbox-muted-findings";
|
||||
export * from "./custom-date-picker";
|
||||
export * from "./custom-provider-inputs";
|
||||
export * from "./data-filters";
|
||||
export * from "./filter-controls";
|
||||
export * from "./filter-summary-strip";
|
||||
|
||||
@@ -5,28 +5,24 @@ import { useState } from "react";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
|
||||
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
|
||||
import { filterFindings } from "@/components/filters/data-filters";
|
||||
import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types";
|
||||
import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for ProviderTypeSelector and AccountsSelector */
|
||||
providers: ProviderProps[];
|
||||
providerIds: string[];
|
||||
providerDetails: { [id: string]: FilterEntity }[];
|
||||
completedScans: ScanProps[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions: string[];
|
||||
@@ -36,73 +32,10 @@ interface FindingsFiltersProps {
|
||||
uniqueGroups: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
|
||||
* Used to render chips in the FilterSummaryStrip.
|
||||
* Typed as Record<FilterParam, string> so TypeScript enforces exhaustiveness — any
|
||||
* addition to FilterParam will cause a compile error here if the label is missing.
|
||||
*/
|
||||
const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[severity__in]": "Severity",
|
||||
"filter[status__in]": "Status",
|
||||
"filter[delta__in]": "Delta",
|
||||
"filter[region__in]": "Region",
|
||||
"filter[service__in]": "Service",
|
||||
"filter[resource_type__in]": "Resource Type",
|
||||
"filter[category__in]": "Category",
|
||||
"filter[resource_groups__in]": "Resource Group",
|
||||
"filter[scan__in]": "Scan ID",
|
||||
"filter[inserted_at]": "Date",
|
||||
"filter[muted]": "Muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a raw filter value into a human-readable display string.
|
||||
* - Provider types: uses shared getProviderDisplayName utility
|
||||
* - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" → "Critical")
|
||||
* - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" → "Fail")
|
||||
* - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.)
|
||||
* - Resource groups: uses getGroupLabel (underscore-delimited)
|
||||
* - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD)
|
||||
* - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation)
|
||||
*/
|
||||
const formatFilterValue = (filterKey: string, value: string): string => {
|
||||
if (!value) return value;
|
||||
if (filterKey === "filter[provider_type__in]") {
|
||||
return getProviderDisplayName(value);
|
||||
}
|
||||
if (filterKey === "filter[severity__in]") {
|
||||
return (
|
||||
SEVERITY_DISPLAY_NAMES[
|
||||
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[status__in]") {
|
||||
return (
|
||||
FINDING_STATUS_DISPLAY_NAMES[
|
||||
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[category__in]") {
|
||||
return getCategoryLabel(value);
|
||||
}
|
||||
if (filterKey === "filter[resource_groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
// Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel
|
||||
if (filterKey === "filter[inserted_at]") {
|
||||
return value;
|
||||
}
|
||||
// Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation
|
||||
return formatLabel(value);
|
||||
};
|
||||
|
||||
export const FindingsFilters = ({
|
||||
providers,
|
||||
providerIds,
|
||||
providerDetails,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
uniqueRegions,
|
||||
@@ -113,17 +46,12 @@ export const FindingsFilters = ({
|
||||
}: FindingsFiltersProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
pendingFilters,
|
||||
setPending,
|
||||
applyAll,
|
||||
discardAll,
|
||||
clearAll,
|
||||
hasChanges,
|
||||
changeCount,
|
||||
getFilterValue,
|
||||
} = useFilterBatch({
|
||||
defaultParams: { "filter[muted]": "false" },
|
||||
const { availableScans } = useRelatedFilters({
|
||||
providerIds,
|
||||
providerDetails,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
enableScanRelation: true,
|
||||
});
|
||||
|
||||
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
|
||||
@@ -164,7 +92,7 @@ export const FindingsFilters = ({
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: completedScanIds,
|
||||
values: availableScans,
|
||||
valueLabelMapping: scanDetails,
|
||||
index: 7,
|
||||
},
|
||||
@@ -172,72 +100,17 @@ export const FindingsFilters = ({
|
||||
|
||||
const hasCustomFilters = customFilters.length > 0;
|
||||
|
||||
// Build FilterChip[] from pendingFilters — one chip per individual value, not per key.
|
||||
// Skip filter[muted]="false" — it is the silent default and should not appear as a chip.
|
||||
const filterChips: FilterChip[] = [];
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
|
||||
values.forEach((value) => {
|
||||
// Do not show a chip for the default muted=false state
|
||||
if (key === "filter[muted]" && value === "false") return;
|
||||
filterChips.push({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
displayValue: formatFilterValue(key, value),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Handler for removing a single chip: update the pending filter to remove that value.
|
||||
// setPending handles both "filter[key]" and "key" formats internally.
|
||||
const handleChipRemove = (filterKey: string, value: string) => {
|
||||
const currentValues = pendingFilters[filterKey] ?? [];
|
||||
const nextValues = currentValues.filter((v) => v !== value);
|
||||
setPending(filterKey, nextValues);
|
||||
};
|
||||
|
||||
// Derive pending muted state for the checkbox.
|
||||
// Note: "filter[muted]" participates in batch mode — applyAll includes it
|
||||
// when present in pending state, and the defaultParams option ensures
|
||||
// filter[muted]=false is applied as a fallback when no muted value is pending.
|
||||
const pendingMutedValue = pendingFilters["filter[muted]"];
|
||||
const mutedChecked =
|
||||
pendingMutedValue !== undefined
|
||||
? pendingMutedValue[0] === "include"
|
||||
: undefined;
|
||||
|
||||
// For the date picker, read from pendingFilters
|
||||
const pendingDateValues = pendingFilters["filter[inserted_at]"];
|
||||
const pendingDateValue =
|
||||
pendingDateValues && pendingDateValues.length > 0
|
||||
? pendingDateValues[0]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* First row: Provider selectors + Muted checkbox + More Filters button + Apply/Clear */}
|
||||
{/* First row: Provider selectors + Muted checkbox + More Filters button + Clear Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_id__in]")}
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
<AccountsSelector providers={providers} />
|
||||
</div>
|
||||
<CustomCheckboxMutedFindings
|
||||
onBatchChange={(filterKey, value) => setPending(filterKey, [value])}
|
||||
checked={mutedChecked}
|
||||
/>
|
||||
<CustomCheckboxMutedFindings />
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -250,55 +123,16 @@ export const FindingsFilters = ({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<ClearFiltersButton
|
||||
showCount
|
||||
onClear={clearAll}
|
||||
pendingCount={
|
||||
Object.entries(pendingFilters).filter(([key, values]) => {
|
||||
if (!values || values.length === 0) return false;
|
||||
// filter[muted]=false is the silent default — don't count it as active
|
||||
if (
|
||||
key === "filter[muted]" &&
|
||||
values.length === 1 &&
|
||||
values[0] === "false"
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}).length
|
||||
}
|
||||
/>
|
||||
<ApplyFiltersButton
|
||||
hasChanges={hasChanges}
|
||||
changeCount={changeCount}
|
||||
onApply={applyAll}
|
||||
onDiscard={discardAll}
|
||||
/>
|
||||
<ClearFiltersButton showCount />
|
||||
</div>
|
||||
|
||||
{/* Summary strip: shown below filter bar when there are pending changes */}
|
||||
<FilterSummaryStrip
|
||||
chips={filterChips}
|
||||
onRemove={handleChipRemove}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
|
||||
{/* Expandable filters section */}
|
||||
{hasCustomFilters && (
|
||||
<ExpandableSection isExpanded={isExpanded}>
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
prependElement={
|
||||
<CustomDatePicker
|
||||
onBatchChange={(filterKey, value) =>
|
||||
setPending(filterKey, value ? [value] : [])
|
||||
}
|
||||
value={pendingDateValue}
|
||||
/>
|
||||
}
|
||||
prependElement={<CustomDatePicker />}
|
||||
hideClearButton
|
||||
mode={DATA_TABLE_FILTER_MODE.BATCH}
|
||||
onBatchChange={setPending}
|
||||
getFilterValue={getFilterValue}
|
||||
/>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
|
||||
@@ -18,5 +18,4 @@ export * from "./separator/separator";
|
||||
export * from "./skeleton/skeleton";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
export * from "./textarea/textarea";
|
||||
export * from "./tooltip";
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilterOption } from "@/types/filters";
|
||||
|
||||
// ── next/navigation mock ────────────────────────────────────────────────────
|
||||
const mockPush = vi.fn();
|
||||
const mockUpdateFilter = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// ── useUrlFilters mock — tracks whether updateFilter is called ───────────────
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({ updateFilter: mockUpdateFilter }),
|
||||
}));
|
||||
|
||||
// ── context (optional dependency used by useUrlFilters) ────────────────────
|
||||
vi.mock("@/contexts", () => ({
|
||||
useFilterTransitionOptional: () => null,
|
||||
}));
|
||||
|
||||
// ── MultiSelect mock — renders a simple <select> backed by onValuesChange ──
|
||||
// This lets us trigger filter changes without needing the full Popover UI.
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({
|
||||
children,
|
||||
values,
|
||||
onValuesChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
values?: string[];
|
||||
onValuesChange?: (values: string[]) => void;
|
||||
}) => (
|
||||
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
|
||||
{children}
|
||||
{/* expose a select to drive value changes in tests */}
|
||||
<select
|
||||
data-testid="multiselect-trigger"
|
||||
multiple
|
||||
defaultValue={values ?? []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map(
|
||||
(o) => o.value,
|
||||
);
|
||||
onValuesChange?.(selected);
|
||||
}}
|
||||
>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="FAIL">FAIL</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
MultiSelectSeparator: () => <hr />,
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
// ── ClearFiltersButton stub ─────────────────────────────────────────────────
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: () => <button type="button">Clear</button>,
|
||||
}));
|
||||
|
||||
// ── Other component stubs ───────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-header/compliance-scan-info",
|
||||
() => ({
|
||||
ComplianceScanInfo: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/helper-filters", () => ({
|
||||
isScanEntity: () => false,
|
||||
isConnectionStatus: () => false,
|
||||
}));
|
||||
|
||||
import { DataTableFilterCustom } from "./data-table-filter-custom";
|
||||
|
||||
// ── Future E2E coverage ────────────────────────────────────────────────────
|
||||
// TODO (E2E): Integration tests for DataTableFilterCustom in batch mode:
|
||||
// - In batch mode, selecting filters does NOT navigate the browser immediately
|
||||
// - Multiple filter selections accumulate in pending state
|
||||
// - Pressing Apply sends a single router.push with all staged filters
|
||||
// - Pressing Discard reverts staged selections to match the current URL
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const severityFilter: FilterOption = {
|
||||
key: "filter[severity__in]",
|
||||
labelCheckboxGroup: "Severity",
|
||||
values: ["critical", "high"],
|
||||
};
|
||||
|
||||
describe("DataTableFilterCustom — batch vs instant mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Default / instant mode ───────────────────────────────────────────────
|
||||
|
||||
describe("instant mode (default)", () => {
|
||||
it("should call updateFilter (URL update) when a selection changes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// When — simulate a value change on the mock select
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then — instant mode pushes to URL via updateFilter
|
||||
expect(mockUpdateFilter).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateFilter).toHaveBeenCalledWith(
|
||||
"filter[severity__in]",
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call onBatchChange in instant mode", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onBatchChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
onBatchChange={onBatchChange}
|
||||
// no mode prop → defaults to "instant"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then
|
||||
expect(onBatchChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render without mode prop (backward compatibility)", () => {
|
||||
// Given / When
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// Then — renders without crashing
|
||||
expect(screen.getByText("Severity")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Batch mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("batch mode", () => {
|
||||
it("should call onBatchChange instead of updateFilter when selection changes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onBatchChange = vi.fn();
|
||||
const getFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={onBatchChange}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then — batch mode notifies caller instead of URL
|
||||
expect(onBatchChange).toHaveBeenCalledTimes(1);
|
||||
expect(onBatchChange).toHaveBeenCalledWith(
|
||||
"filter[severity__in]",
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockUpdateFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should read selected values from getFilterValue in batch mode", () => {
|
||||
// Given — batch mode with pre-seeded pending state
|
||||
const onBatchChange = vi.fn();
|
||||
const getFilterValue = vi
|
||||
.fn()
|
||||
.mockImplementation((key: string) =>
|
||||
key === "filter[severity__in]" ? ["critical"] : [],
|
||||
);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={onBatchChange}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — the mock multiselect receives the pending values
|
||||
const multiselect = screen.getByTestId("multiselect");
|
||||
expect(multiselect).toHaveAttribute(
|
||||
"data-values",
|
||||
JSON.stringify(["critical"]),
|
||||
);
|
||||
|
||||
// getFilterValue must have been called for the filter key
|
||||
expect(getFilterValue).toHaveBeenCalledWith("filter[severity__in]");
|
||||
});
|
||||
|
||||
it("should pass empty array to MultiSelect when getFilterValue returns empty", () => {
|
||||
// Given
|
||||
const getFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={vi.fn()}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — multiselect gets empty values
|
||||
const multiselect = screen.getByTestId("multiselect");
|
||||
expect(multiselect).toHaveAttribute("data-values", JSON.stringify([]));
|
||||
});
|
||||
});
|
||||
|
||||
// ── hideClearButton ──────────────────────────────────────────────────────
|
||||
|
||||
describe("hideClearButton prop", () => {
|
||||
it("should hide the ClearFiltersButton when hideClearButton is true", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
hideClearButton={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the ClearFiltersButton by default", () => {
|
||||
// Given / When
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
ProviderEntity,
|
||||
ScanEntity,
|
||||
} from "@/types";
|
||||
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
|
||||
import { ProviderConnectionStatus } from "@/types/providers";
|
||||
|
||||
export interface DataTableFilterCustomProps {
|
||||
@@ -31,33 +30,12 @@ export interface DataTableFilterCustomProps {
|
||||
prependElement?: React.ReactNode;
|
||||
/** Hide the clear filters button and active badges (useful when parent manages this) */
|
||||
hideClearButton?: boolean;
|
||||
/**
|
||||
* Controls when filter selections are pushed to the URL.
|
||||
* - "instant" (default): each selection immediately updates the URL (legacy behavior, backward-compatible).
|
||||
* - "batch": selections accumulate in pending state; caller manages when to push URL.
|
||||
*/
|
||||
mode?: DataTableFilterMode;
|
||||
/**
|
||||
* Called in "batch" mode when a filter value changes.
|
||||
* The key is the raw filter key (e.g. "filter[severity__in]" or "severity__in").
|
||||
* Only invoked when mode === "batch".
|
||||
*/
|
||||
onBatchChange?: (filterKey: string, values: string[]) => void;
|
||||
/**
|
||||
* Returns the current selected values for a filter in "batch" mode.
|
||||
* Replaces reading from URL searchParams when mode === "batch".
|
||||
* Only used when mode === "batch".
|
||||
*/
|
||||
getFilterValue?: (filterKey: string) => string[];
|
||||
}
|
||||
|
||||
export const DataTableFilterCustom = ({
|
||||
filters,
|
||||
prependElement,
|
||||
hideClearButton = false,
|
||||
mode = DATA_TABLE_FILTER_MODE.INSTANT,
|
||||
onBatchChange,
|
||||
getFilterValue,
|
||||
}: DataTableFilterCustomProps) => {
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -131,13 +109,6 @@ export const DataTableFilterCustom = ({
|
||||
};
|
||||
|
||||
const pushDropdownFilter = (filter: FilterOption, values: string[]) => {
|
||||
if (mode === DATA_TABLE_FILTER_MODE.BATCH && onBatchChange) {
|
||||
// In batch mode, notify the caller instead of updating the URL
|
||||
onBatchChange(filter.key, values);
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mode (default): push to URL immediately
|
||||
// If this filter defaults to "all selected" and the user selected all items,
|
||||
// clear the URL param to represent "no specific filter" (i.e., all).
|
||||
const allSelected =
|
||||
@@ -152,12 +123,6 @@ export const DataTableFilterCustom = ({
|
||||
};
|
||||
|
||||
const getSelectedValues = (filter: FilterOption): string[] => {
|
||||
if (mode === DATA_TABLE_FILTER_MODE.BATCH && getFilterValue) {
|
||||
// In batch mode, read from pending state provided by the caller
|
||||
return getFilterValue(filter.key);
|
||||
}
|
||||
|
||||
// Instant mode (default): read from URL searchParams
|
||||
const filterKey = filter.key.startsWith("filter[")
|
||||
? filter.key
|
||||
: `filter[${filter.key}]`;
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// --- Mock next/navigation ---
|
||||
const mockPush = vi.fn();
|
||||
let mockSearchParamsValue = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => mockSearchParamsValue,
|
||||
}));
|
||||
|
||||
import { useFilterBatch } from "./use-filter-batch";
|
||||
|
||||
/**
|
||||
* Helper to re-assign the mocked searchParams and re-import the hook.
|
||||
* Because useSearchParams() is called inside the hook on every render,
|
||||
* we just update the module-level variable and force a re-render.
|
||||
*/
|
||||
function setSearchParams(params: Record<string, string>) {
|
||||
mockSearchParamsValue = new URLSearchParams(params);
|
||||
}
|
||||
|
||||
describe("useFilterBatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParamsValue = new URLSearchParams();
|
||||
});
|
||||
|
||||
// ── Initial state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should have empty pending filters when there are no URL params", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then
|
||||
expect(result.current.pendingFilters).toEqual({});
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.changeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should initialize pending filters from URL search params on mount", () => {
|
||||
// Given
|
||||
setSearchParams({
|
||||
"filter[severity__in]": "critical,high",
|
||||
"filter[status__in]": "FAIL",
|
||||
});
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then
|
||||
expect(result.current.pendingFilters).toEqual({
|
||||
"filter[severity__in]": ["critical", "high"],
|
||||
"filter[status__in]": ["FAIL"],
|
||||
});
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Excluded keys ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("excluded keys", () => {
|
||||
it("should exclude filter[search] from batch operations", () => {
|
||||
// Given — search is excluded from batch; muted now participates in batch
|
||||
setSearchParams({
|
||||
"filter[search]": "some-search-term",
|
||||
"filter[muted]": "false",
|
||||
"filter[severity__in]": "critical",
|
||||
});
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then — severity and muted are in pendingFilters; search is excluded
|
||||
expect(result.current.pendingFilters).toEqual({
|
||||
"filter[muted]": ["false"],
|
||||
"filter[severity__in]": ["critical"],
|
||||
});
|
||||
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
|
||||
// muted is now part of batch (not excluded)
|
||||
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setPending ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setPending", () => {
|
||||
it("should update pending state for a given key", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
||||
"critical",
|
||||
"high",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should auto-prefix key with filter[] when not already prefixed", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setPending("severity__in", ["critical"]);
|
||||
});
|
||||
|
||||
// Then — key is stored with filter[] prefix
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
||||
"critical",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should keep the key but with empty array when values is empty", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Pre-condition: set a value first
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
});
|
||||
|
||||
// When — clear the filter by passing empty array
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", []);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getFilterValue ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getFilterValue", () => {
|
||||
it("should return pending values for a key that has been set", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
||||
});
|
||||
|
||||
// When
|
||||
const values = result.current.getFilterValue("filter[severity__in]");
|
||||
|
||||
// Then
|
||||
expect(values).toEqual(["critical", "high"]);
|
||||
});
|
||||
|
||||
it("should return an empty array for a key that has not been set", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// When
|
||||
const values = result.current.getFilterValue("filter[unknown_key]");
|
||||
|
||||
// Then
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
|
||||
it("should auto-prefix key when calling getFilterValue without filter[]", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
});
|
||||
|
||||
// When — key without prefix
|
||||
const values = result.current.getFilterValue("severity__in");
|
||||
|
||||
// Then
|
||||
expect(values).toEqual(["critical"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasChanges & changeCount ───────────────────────────────────────────────
|
||||
|
||||
describe("hasChanges", () => {
|
||||
it("should be false when pending matches the URL state", () => {
|
||||
// Given
|
||||
setSearchParams({ "filter[severity__in]": "critical" });
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then — initial state = URL state, so no changes
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true when pending differs from the URL state", () => {
|
||||
// Given
|
||||
setSearchParams({ "filter[severity__in]": "critical" });
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// When — change pending
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeCount", () => {
|
||||
it("should be 0 when pending matches URL", () => {
|
||||
// Given
|
||||
setSearchParams({ "filter[severity__in]": "critical" });
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then
|
||||
expect(result.current.changeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should count the number of changed filter keys", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// When — add two different pending filters
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
// Then — 2 keys differ from URL (which has neither)
|
||||
expect(result.current.changeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should decrease changeCount when a pending filter is reset to match URL", () => {
|
||||
// Given — URL has severity=critical, pending adds status=FAIL
|
||||
setSearchParams({ "filter[severity__in]": "critical" });
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
expect(result.current.changeCount).toBe(1);
|
||||
|
||||
// When — reset status back to empty (matching URL which has no status)
|
||||
act(() => {
|
||||
result.current.setPending("filter[status__in]", []);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.changeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyAll ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyAll", () => {
|
||||
it("should call router.push with all pending filters serialized as URL params", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.applyAll();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
const calledUrl: string = mockPush.mock.calls[0][0];
|
||||
expect(calledUrl).toContain("filter%5Bseverity__in%5D=critical%2Chigh");
|
||||
});
|
||||
|
||||
it("should reset page number when a page param exists in the URL", () => {
|
||||
// Given — simulate a URL that already has page=3
|
||||
mockSearchParamsValue = new URLSearchParams({
|
||||
"filter[severity__in]": "critical",
|
||||
page: "3",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.applyAll();
|
||||
});
|
||||
|
||||
// Then — page should be reset to 1
|
||||
const calledUrl: string = mockPush.mock.calls[0][0];
|
||||
expect(calledUrl).toContain("page=1");
|
||||
});
|
||||
|
||||
it("should preserve excluded params (filter[search], filter[muted]) in the URL", () => {
|
||||
// Given
|
||||
mockSearchParamsValue = new URLSearchParams({
|
||||
"filter[search]": "my-search",
|
||||
"filter[muted]": "false",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.applyAll();
|
||||
});
|
||||
|
||||
// Then — search and muted should still be present
|
||||
const calledUrl: string = mockPush.mock.calls[0][0];
|
||||
expect(calledUrl).toContain("filter%5Bsearch%5D=my-search");
|
||||
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
|
||||
});
|
||||
});
|
||||
|
||||
// ── clearAll ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("clearAll", () => {
|
||||
it("should clear all pending filters including provider and account keys", () => {
|
||||
// Given — user has pending provider, account, severity, and status filters
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[provider_type__in]", [
|
||||
"aws",
|
||||
"azure",
|
||||
]);
|
||||
result.current.setPending("filter[provider_id__in]", [
|
||||
"provider-uuid-1",
|
||||
]);
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
// Pre-condition — all filters are pending
|
||||
expect(
|
||||
result.current.pendingFilters["filter[provider_type__in]"],
|
||||
).toEqual(["aws", "azure"]);
|
||||
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
|
||||
"provider-uuid-1",
|
||||
]);
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
||||
"critical",
|
||||
]);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.clearAll();
|
||||
});
|
||||
|
||||
// Then — pending state must be TRULY EMPTY (no keys at all, not even with empty arrays)
|
||||
expect(result.current.pendingFilters).toEqual({});
|
||||
// getFilterValue normalises missing keys to [] so all selectors show "all selected"
|
||||
expect(
|
||||
result.current.getFilterValue("filter[provider_type__in]"),
|
||||
).toEqual([]);
|
||||
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
|
||||
expect(result.current.getFilterValue("filter[status__in]")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should also clear provider/account keys that came from the URL (applied state)", () => {
|
||||
// Given — URL has provider and account filters applied
|
||||
setSearchParams({
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[provider_id__in]": "provider-uuid-1",
|
||||
"filter[severity__in]": "critical",
|
||||
});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Pre-condition — filters are loaded from URL into pending
|
||||
expect(
|
||||
result.current.pendingFilters["filter[provider_type__in]"],
|
||||
).toEqual(["aws"]);
|
||||
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
|
||||
"provider-uuid-1",
|
||||
]);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.clearAll();
|
||||
});
|
||||
|
||||
// Then — pending state must be truly empty (no keys, not { key: [] })
|
||||
expect(result.current.pendingFilters).toEqual({});
|
||||
// provider and account must be cleared even though they came from the URL
|
||||
expect(
|
||||
result.current.getFilterValue("filter[provider_type__in]"),
|
||||
).toEqual([]);
|
||||
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should mark hasChanges as true after clear when URL still has applied filters", () => {
|
||||
// Given — URL has filters applied
|
||||
setSearchParams({
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[severity__in]": "critical",
|
||||
});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Pre-condition — no pending changes (matches URL)
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
|
||||
// When — clear all
|
||||
act(() => {
|
||||
result.current.clearAll();
|
||||
});
|
||||
|
||||
// Then — hasChanges must be true (pending is empty, URL still has filters)
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT clear excluded keys (filter[search]) but DOES clear filter[muted]", () => {
|
||||
// Given — URL has search (excluded) plus muted and severity (both in batch)
|
||||
setSearchParams({
|
||||
"filter[search]": "my-search",
|
||||
"filter[muted]": "false",
|
||||
"filter[severity__in]": "critical",
|
||||
});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Pre-condition — muted and severity are in pendingFilters; search is excluded
|
||||
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
|
||||
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.clearAll();
|
||||
});
|
||||
|
||||
// Then — severity and muted are cleared; search remains excluded (undefined in pending)
|
||||
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
|
||||
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
|
||||
// muted is a batch key, so it gets cleared by clearAll
|
||||
expect(result.current.pendingFilters["filter[muted]"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should clear applied URL filters even if they were explicitly removed from pendingFilters", () => {
|
||||
// This covers the edge case where pendingFilters diverged from URL state
|
||||
// (e.g., URL has provider filter but the key was removed from pending via removePending)
|
||||
setSearchParams({
|
||||
"filter[provider_type__in]": "gcp",
|
||||
"filter[severity__in]": "high",
|
||||
});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Remove the provider key from pending (diverge from URL state)
|
||||
act(() => {
|
||||
result.current.removePending("filter[provider_type__in]");
|
||||
});
|
||||
|
||||
// Pre-condition — provider is gone from pending but still in URL
|
||||
expect(
|
||||
result.current.pendingFilters["filter[provider_type__in]"],
|
||||
).toBeUndefined();
|
||||
|
||||
// When — clearAll should clear BOTH pending keys AND applied URL keys
|
||||
act(() => {
|
||||
result.current.clearAll();
|
||||
});
|
||||
|
||||
// Then — severity is cleared
|
||||
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
|
||||
// provider_type__in was in the URL (applied state), so clearAll must handle it
|
||||
expect(
|
||||
result.current.getFilterValue("filter[provider_type__in]"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── discardAll ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("discardAll", () => {
|
||||
it("should reset pending to match the current URL state", () => {
|
||||
// Given — URL has severity=critical
|
||||
setSearchParams({ "filter[severity__in]": "critical" });
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Add a pending change
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.discardAll();
|
||||
});
|
||||
|
||||
// Then — pending should match URL again
|
||||
expect(result.current.pendingFilters).toEqual({
|
||||
"filter[severity__in]": ["critical"],
|
||||
});
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL sync (back/forward) ────────────────────────────────────────────────
|
||||
|
||||
describe("URL sync", () => {
|
||||
it("should re-sync pending state when searchParams change (e.g., browser back/forward)", () => {
|
||||
// Given — initial empty URL
|
||||
setSearchParams({});
|
||||
const { result, rerender } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Add a pending change
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
});
|
||||
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
||||
"critical",
|
||||
]);
|
||||
|
||||
// When — simulate browser back by changing searchParams externally
|
||||
act(() => {
|
||||
mockSearchParamsValue = new URLSearchParams({
|
||||
"filter[severity__in]": "high",
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
|
||||
// Then — pending should re-sync from new URL
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
||||
"high",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removePending ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("removePending", () => {
|
||||
it("should remove a single filter key from pending state", () => {
|
||||
// Given
|
||||
setSearchParams({});
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
result.current.setPending("filter[status__in]", ["FAIL"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.removePending("filter[severity__in]");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
result.current.pendingFilters["filter[severity__in]"],
|
||||
).toBeUndefined();
|
||||
expect(result.current.pendingFilters["filter[status__in]"]).toEqual([
|
||||
"FAIL",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Filters that are managed by the batch hook (excludes system defaults)
|
||||
const EXCLUDED_FROM_BATCH = ["filter[search]"];
|
||||
|
||||
/**
|
||||
* Snapshot of pending (un-applied) filter state.
|
||||
* Keys are raw filter param names, e.g. "filter[severity__in]".
|
||||
* Values are arrays of selected option strings.
|
||||
*/
|
||||
export interface PendingFilters {
|
||||
[filterKey: string]: string[];
|
||||
}
|
||||
|
||||
export interface UseFilterBatchReturn {
|
||||
/** Current pending filter values — local state, not yet in URL */
|
||||
pendingFilters: PendingFilters;
|
||||
/** Update a single pending filter. Does NOT touch the URL. */
|
||||
setPending: (key: string, values: string[]) => void;
|
||||
/** Apply all pending filters to URL in a single router.push */
|
||||
applyAll: () => void;
|
||||
/** Discard all pending changes, reset pending to the current URL state */
|
||||
discardAll: () => void;
|
||||
/**
|
||||
* Clear all pending filters to an empty state (no filters selected).
|
||||
* Unlike `discardAll`, this does NOT reset to the URL state — it sets
|
||||
* pending to `{}` (truly empty). The user must click Apply to push
|
||||
* the empty state to the URL.
|
||||
* Includes provider/account keys and all batch-managed filter keys.
|
||||
*/
|
||||
clearAll: () => void;
|
||||
/** Remove a single filter key from pending state */
|
||||
removePending: (key: string) => void;
|
||||
/** Whether pending state differs from the current URL */
|
||||
hasChanges: boolean;
|
||||
/** Number of filter keys that differ from the URL */
|
||||
changeCount: number;
|
||||
/** Get current value for a filter (pending if set, else from URL) */
|
||||
getFilterValue: (key: string) => string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the applied (URL-backed) filter state from `searchParams`.
|
||||
* Returns only the filter keys that are not excluded from batch management.
|
||||
*/
|
||||
function deriveAppliedFromUrl(searchParams: URLSearchParams): PendingFilters {
|
||||
const applied: PendingFilters = {};
|
||||
|
||||
Array.from(searchParams.entries()).forEach(([key, value]) => {
|
||||
if (!key.startsWith("filter[")) return;
|
||||
if (EXCLUDED_FROM_BATCH.includes(key)) return;
|
||||
if (!value) return;
|
||||
|
||||
applied[key] = value.split(",").filter(Boolean);
|
||||
});
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two PendingFilters objects for shallow equality.
|
||||
* Two states are equal when they contain the same keys and the same sorted values.
|
||||
*/
|
||||
function areFiltersEqual(a: PendingFilters, b: PendingFilters): boolean {
|
||||
const keysA = Object.keys(a).filter((k) => a[k].length > 0);
|
||||
const keysB = Object.keys(b).filter((k) => b[k].length > 0);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
return keysA.every((key) => {
|
||||
if (!b[key]) return false;
|
||||
const sortedA = [...a[key]].sort();
|
||||
const sortedB = [...b[key]].sort();
|
||||
if (sortedA.length !== sortedB.length) return false;
|
||||
return sortedA.every((v, i) => v === sortedB[i]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of filter keys that differ between pending and applied.
|
||||
*/
|
||||
function countChanges(
|
||||
pending: PendingFilters,
|
||||
applied: PendingFilters,
|
||||
): number {
|
||||
const pendingKeys = Object.keys(pending).filter((k) => pending[k].length > 0);
|
||||
const appliedKeys = Object.keys(applied).filter((k) => applied[k].length > 0);
|
||||
|
||||
// Merge all unique keys without Set iteration
|
||||
const allKeys = Array.from(new Set([...pendingKeys, ...appliedKeys]));
|
||||
|
||||
let count = 0;
|
||||
allKeys.forEach((key) => {
|
||||
const p = pending[key] ?? [];
|
||||
const a = applied[key] ?? [];
|
||||
const sortedP = [...p].sort();
|
||||
const sortedA = [...a].sort();
|
||||
if (
|
||||
sortedP.length !== sortedA.length ||
|
||||
!sortedP.every((v, i) => v === sortedA[i])
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export interface UseFilterBatchOptions {
|
||||
/**
|
||||
* Default URL params to apply when applyAll() is called and they are not
|
||||
* already present in the params. Useful for page-level filter defaults
|
||||
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
|
||||
*/
|
||||
defaultParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a two-state (pending → applied) filter model for the Findings view.
|
||||
*
|
||||
* - Pending state lives only in this hook (React `useState`).
|
||||
* - Applied state is owned by the URL (`searchParams`).
|
||||
* - `applyAll()` performs a single `router.push()` with the full pending state.
|
||||
* - `discardAll()` resets pending to match the current URL.
|
||||
* - Browser back/forward automatically re-syncs pending state from the new URL.
|
||||
*/
|
||||
export const useFilterBatch = (
|
||||
options?: UseFilterBatchOptions,
|
||||
): UseFilterBatchReturn => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [pendingFilters, setPendingFilters] = useState<PendingFilters>(() =>
|
||||
deriveAppliedFromUrl(new URLSearchParams(searchParams.toString())),
|
||||
);
|
||||
|
||||
// Sync pending state whenever the URL changes (back/forward nav or external update).
|
||||
// `searchParams` from useSearchParams() is stable between renders in Next.js App Router.
|
||||
useEffect(() => {
|
||||
const applied = deriveAppliedFromUrl(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
setPendingFilters(applied);
|
||||
}, [searchParams]);
|
||||
|
||||
const setPending = (key: string, values: string[]) => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
[filterKey]: values,
|
||||
}));
|
||||
};
|
||||
|
||||
const removePending = (key: string) => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
setPendingFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[filterKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const applyAll = () => {
|
||||
// Start from the current URL params to preserve non-batch params.
|
||||
// Only filter[search] is excluded from batch management and preserved from the URL as-is.
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Remove all existing batch-managed filter params
|
||||
Array.from(params.keys()).forEach((key) => {
|
||||
if (key.startsWith("filter[") && !EXCLUDED_FROM_BATCH.includes(key)) {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Write the pending state
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
const nonEmpty = values.filter(Boolean);
|
||||
if (nonEmpty.length > 0) {
|
||||
params.set(key, nonEmpty.join(","));
|
||||
}
|
||||
});
|
||||
|
||||
// Apply caller-supplied defaults for any params not already set
|
||||
if (options?.defaultParams) {
|
||||
Object.entries(options.defaultParams).forEach(([key, value]) => {
|
||||
if (!params.has(key)) {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset pagination
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
router.push(targetUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const discardAll = () => {
|
||||
const applied = deriveAppliedFromUrl(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
setPendingFilters(applied);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears ALL pending batch filters to an empty state (no filters selected).
|
||||
*
|
||||
* Unlike `discardAll`, this resets pending to `{}` — not to the current URL
|
||||
* state. This covers both:
|
||||
* - Keys that are already in `pendingFilters` (pending-only or URL-loaded)
|
||||
* - Keys that are in the applied (URL) state but were removed from pending
|
||||
* via `removePending` (edge case: diverged state)
|
||||
*
|
||||
* The user must click Apply to push the empty state to the URL.
|
||||
* `applyAll()` removes all batch-managed URL params first, so even keys
|
||||
* absent from `pendingFilters` will be removed from the URL on apply.
|
||||
*/
|
||||
const clearAll = () => {
|
||||
// Return a truly empty object — no filters pending at all.
|
||||
// `getFilterValue` normalises missing keys to [] so selectors will show
|
||||
// their "all selected" / placeholder state immediately.
|
||||
setPendingFilters({});
|
||||
};
|
||||
|
||||
const getFilterValue = (key: string): string[] => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
return pendingFilters[filterKey] ?? [];
|
||||
};
|
||||
|
||||
const appliedFilters = deriveAppliedFromUrl(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
|
||||
const changeCount = hasChanges
|
||||
? countChanges(pendingFilters, appliedFilters)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
pendingFilters,
|
||||
setPending,
|
||||
applyAll,
|
||||
discardAll,
|
||||
clearAll,
|
||||
removePending,
|
||||
hasChanges,
|
||||
changeCount,
|
||||
getFilterValue,
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH = 10000;
|
||||
export const CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE =
|
||||
"Only read-only queries are allowed";
|
||||
const CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS =
|
||||
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
||||
const CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS = [
|
||||
/\bCREATE\b/i,
|
||||
/\bMERGE\b/i,
|
||||
/\bSET\b/i,
|
||||
/\bREMOVE\b/i,
|
||||
/\bDELETE\b/i,
|
||||
/\bDETACH\s+DELETE\b/i,
|
||||
/\bDROP\b/i,
|
||||
/\bLOAD\s+CSV\b/i,
|
||||
/\bapoc\.(?:load|import|export|cypher|systemdb|config|periodic|do|trigger|custom)\b/i,
|
||||
] as const;
|
||||
|
||||
const containsBlockedOperation = (query: string): boolean => {
|
||||
const normalizedQuery = query.replace(
|
||||
CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS,
|
||||
"",
|
||||
);
|
||||
|
||||
return CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS.some((pattern) =>
|
||||
pattern.test(normalizedQuery),
|
||||
);
|
||||
};
|
||||
|
||||
export const customAttackPathQuerySchema = z
|
||||
.string()
|
||||
.max(
|
||||
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
|
||||
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
|
||||
)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: "Custom query cannot be empty",
|
||||
})
|
||||
.refine((value) => !containsBlockedOperation(value), {
|
||||
message: CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
});
|
||||
@@ -81,18 +81,6 @@ export const DATA_TYPES = {
|
||||
|
||||
type DataType = (typeof DATA_TYPES)[keyof typeof DATA_TYPES];
|
||||
|
||||
export const QUERY_PARAMETER_INPUT_TYPES = {
|
||||
TEXT: "text",
|
||||
TEXTAREA: "textarea",
|
||||
} as const;
|
||||
|
||||
export type QueryParameterInputType =
|
||||
(typeof QUERY_PARAMETER_INPUT_TYPES)[keyof typeof QUERY_PARAMETER_INPUT_TYPES];
|
||||
|
||||
export const ATTACK_PATH_QUERY_IDS = {
|
||||
CUSTOM: "__custom-open-cypher__",
|
||||
} as const;
|
||||
|
||||
// Query Types
|
||||
export interface AttackPathQueryParameter {
|
||||
name: string;
|
||||
@@ -101,7 +89,6 @@ export interface AttackPathQueryParameter {
|
||||
description: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
input_type?: QueryParameterInputType;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryAttribution {
|
||||
@@ -109,11 +96,6 @@ export interface AttackPathQueryAttribution {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryDocumentationLink {
|
||||
text: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryAttributes {
|
||||
name: string;
|
||||
short_description: string;
|
||||
@@ -121,7 +103,6 @@ export interface AttackPathQueryAttributes {
|
||||
provider: string;
|
||||
parameters: AttackPathQueryParameter[];
|
||||
attribution: AttackPathQueryAttribution | null;
|
||||
documentation_link?: AttackPathQueryDocumentationLink | null;
|
||||
}
|
||||
|
||||
export interface AttackPathQuery {
|
||||
@@ -134,24 +115,6 @@ export interface AttackPathQueriesResponse {
|
||||
data: AttackPathQuery[];
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchemaAttributes {
|
||||
id: string;
|
||||
provider: string;
|
||||
cartography_version: string;
|
||||
schema_url: string;
|
||||
raw_schema_url: string;
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchema {
|
||||
type: "attack-paths-cartography-schemas";
|
||||
id: string;
|
||||
attributes: AttackPathCartographySchemaAttributes;
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchemaResponse {
|
||||
data: AttackPathCartographySchema;
|
||||
}
|
||||
|
||||
// Graph Data Types
|
||||
// Property values from graph nodes can be any primitive type or arrays
|
||||
export type GraphNodePropertyValue =
|
||||
@@ -293,16 +256,3 @@ export interface ExecuteQueryRequestData {
|
||||
export interface ExecuteQueryRequest {
|
||||
data: ExecuteQueryRequestData;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequestAttributes {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequestData {
|
||||
type: "attack-paths-custom-query-run-requests";
|
||||
attributes: ExecuteCustomQueryRequestAttributes;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequest {
|
||||
data: ExecuteCustomQueryRequestData;
|
||||
}
|
||||
|
||||
@@ -95,16 +95,6 @@ export const FINDING_STATUS = {
|
||||
export type FindingStatus =
|
||||
(typeof FINDING_STATUS)[keyof typeof FINDING_STATUS];
|
||||
|
||||
/**
|
||||
* Maps raw finding status values to human-readable display strings.
|
||||
* Follows the same pattern as SEVERITY_DISPLAY_NAMES in types/severities.ts.
|
||||
*/
|
||||
export const FINDING_STATUS_DISPLAY_NAMES: Record<FindingStatus, string> = {
|
||||
PASS: "Pass",
|
||||
FAIL: "Fail",
|
||||
MANUAL: "Manual",
|
||||
};
|
||||
|
||||
export const SEVERITY = {
|
||||
INFORMATIONAL: "informational",
|
||||
LOW: "low",
|
||||
|
||||
@@ -42,36 +42,3 @@ export enum FilterType {
|
||||
CATEGORY = "category__in",
|
||||
RESOURCE_GROUPS = "resource_groups__in",
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls the filter dispatch behavior of DataTableFilterCustom.
|
||||
* - "instant": every selection immediately updates the URL (legacy/default behavior)
|
||||
* - "batch": selections accumulate in pending state; URL only updates on explicit apply
|
||||
*/
|
||||
export const DATA_TABLE_FILTER_MODE = {
|
||||
INSTANT: "instant",
|
||||
BATCH: "batch",
|
||||
} as const;
|
||||
|
||||
export type DataTableFilterMode =
|
||||
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
|
||||
|
||||
/**
|
||||
* Exhaustive union of all URL filter param keys used in Findings filters.
|
||||
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
|
||||
* param-keyed records stay in sync with the actual filter surface.
|
||||
*/
|
||||
export type FilterParam =
|
||||
| "filter[provider_type__in]"
|
||||
| "filter[provider_id__in]"
|
||||
| "filter[severity__in]"
|
||||
| "filter[status__in]"
|
||||
| "filter[delta__in]"
|
||||
| "filter[region__in]"
|
||||
| "filter[service__in]"
|
||||
| "filter[resource_type__in]"
|
||||
| "filter[category__in]"
|
||||
| "filter[resource_groups__in]"
|
||||
| "filter[scan__in]"
|
||||
| "filter[inserted_at]"
|
||||
| "filter[muted]";
|
||||
|
||||
Reference in New Issue
Block a user