mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
chore: merge master and resolve task watcher conflict
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -695,6 +696,45 @@ def _process_finding_micro_batch(
|
||||
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
|
||||
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
|
||||
"""
|
||||
|
||||
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"provider": provider_instance,
|
||||
"uid": finding.resource_uid,
|
||||
"region": finding.region,
|
||||
"service": finding.service_name,
|
||||
"type": finding.resource_type,
|
||||
"name": finding.resource_name,
|
||||
"groups": [group] if group else None,
|
||||
}
|
||||
|
||||
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
resource_instance = Resource.objects.create(
|
||||
**build_resource_defaults_from_finding(finding)
|
||||
)
|
||||
except IntegrityError:
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
raise
|
||||
|
||||
return cache_resource(resource_uid, resource_instance)
|
||||
|
||||
# Accumulate objects for bulk operations
|
||||
findings_to_create = []
|
||||
dirty_resources = {}
|
||||
@@ -733,7 +773,103 @@ def _process_finding_micro_batch(
|
||||
|
||||
# All DB writes for this micro-batch run inside ONE rls_transaction,
|
||||
# with deadlock-retry at micro-batch granularity instead of per-finding.
|
||||
missing_cache_value = object()
|
||||
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
|
||||
resource_cache_originals: dict[str, Resource | object] = {}
|
||||
failed_count_originals: dict[str, int | None] = {}
|
||||
resource_field_originals: dict[str, dict[str, Any]] = {}
|
||||
tag_cache_original = dict(tag_cache)
|
||||
scan_resource_cache_original = set(scan_resource_cache)
|
||||
scan_categories_cache_original = {
|
||||
key: value.copy() for key, value in scan_categories_cache.items()
|
||||
}
|
||||
scan_resource_groups_cache_original = {
|
||||
key: value.copy() for key, value in scan_resource_groups_cache.items()
|
||||
}
|
||||
group_resources_cache_original = {
|
||||
key: set(value) for key, value in group_resources_cache.items()
|
||||
}
|
||||
|
||||
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
|
||||
if resource_uid not in resource_cache_originals:
|
||||
resource_cache_originals[resource_uid] = resource_cache.get(
|
||||
resource_uid, missing_cache_value
|
||||
)
|
||||
resource_cache[resource_uid] = resource_instance
|
||||
if resource_uid not in resource_failed_findings_cache:
|
||||
failed_count_originals[resource_uid] = None
|
||||
resource_failed_findings_cache[resource_uid] = 0
|
||||
return resource_instance
|
||||
|
||||
def snapshot_failed_count(resource_uid: str) -> None:
|
||||
if resource_uid not in failed_count_originals:
|
||||
failed_count_originals[resource_uid] = (
|
||||
resource_failed_findings_cache.get(resource_uid)
|
||||
)
|
||||
|
||||
def snapshot_resource_fields(
|
||||
resource_uid: str, resource_instance: Resource
|
||||
) -> None:
|
||||
if resource_uid in resource_field_originals:
|
||||
return
|
||||
resource_field_originals[resource_uid] = {
|
||||
field: copy.deepcopy(getattr(resource_instance, field))
|
||||
for field in (
|
||||
"name",
|
||||
"metadata",
|
||||
"details",
|
||||
"partition",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
def restore_attempt_caches() -> None:
|
||||
for resource_uid, original_fields in resource_field_originals.items():
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
continue
|
||||
for field, value in original_fields.items():
|
||||
setattr(resource_instance, field, value)
|
||||
for resource_uid, original_resource in resource_cache_originals.items():
|
||||
if original_resource is missing_cache_value:
|
||||
resource_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_cache[resource_uid] = original_resource
|
||||
for resource_uid, original_count in failed_count_originals.items():
|
||||
if original_count is None:
|
||||
resource_failed_findings_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_failed_findings_cache[resource_uid] = original_count
|
||||
tag_cache.clear()
|
||||
tag_cache.update(tag_cache_original)
|
||||
scan_resource_cache.clear()
|
||||
scan_resource_cache.update(scan_resource_cache_original)
|
||||
scan_categories_cache.clear()
|
||||
scan_categories_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_categories_cache_original.items()
|
||||
}
|
||||
)
|
||||
scan_resource_groups_cache.clear()
|
||||
scan_resource_groups_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_resource_groups_cache_original.items()
|
||||
}
|
||||
)
|
||||
group_resources_cache.clear()
|
||||
group_resources_cache.update(
|
||||
{
|
||||
key: set(value)
|
||||
for key, value in group_resources_cache_original.items()
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
# 1) Pre-resolve Resources in bulk
|
||||
@@ -768,19 +904,8 @@ def _process_finding_micro_batch(
|
||||
resources_to_create = []
|
||||
for uid in missing_uids:
|
||||
f = first_finding_per_uid[uid]
|
||||
check_metadata = f.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
resources_to_create.append(
|
||||
Resource(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_instance,
|
||||
uid=uid,
|
||||
region=f.region,
|
||||
service=f.service_name,
|
||||
type=f.resource_type,
|
||||
name=f.resource_name,
|
||||
groups=[group] if group else None,
|
||||
)
|
||||
Resource(**build_resource_defaults_from_finding(f))
|
||||
)
|
||||
Resource.objects.bulk_create(
|
||||
resources_to_create,
|
||||
@@ -801,8 +926,7 @@ def _process_finding_micro_batch(
|
||||
}
|
||||
)
|
||||
for uid, r in existing_resources.items():
|
||||
resource_cache[uid] = r
|
||||
resource_failed_findings_cache.setdefault(uid, 0)
|
||||
cache_resource(uid, r)
|
||||
|
||||
# 2) Pre-resolve ResourceTags in bulk
|
||||
batch_tag_kv: set[tuple[str, str]] = set()
|
||||
@@ -848,47 +972,50 @@ def _process_finding_micro_batch(
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
# Should be unreachable after the pre-resolve step. Defensive log.
|
||||
logger.error(
|
||||
f"Resource {resource_uid} missing from cache after pre-resolve "
|
||||
f"on scan {scan_instance.id}; skipping finding."
|
||||
)
|
||||
continue
|
||||
resource_instance = recover_resource_after_cache_miss(finding)
|
||||
|
||||
# Detect resource field changes (defer save until end-of-batch bulk_update).
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
updated = False
|
||||
if finding.region and resource_instance.region != finding.region:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.region = finding.region
|
||||
updated = True
|
||||
if (
|
||||
finding.resource_name
|
||||
and resource_instance.name != finding.resource_name
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.name = finding.resource_name
|
||||
updated = True
|
||||
if resource_instance.service != finding.service_name:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.service = finding.service_name
|
||||
updated = True
|
||||
if resource_instance.type != finding.resource_type:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.type = finding.resource_type
|
||||
updated = True
|
||||
if resource_instance.metadata != finding.resource_metadata:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.metadata = json.dumps(
|
||||
finding.resource_metadata, cls=CustomEncoder
|
||||
)
|
||||
updated = True
|
||||
if resource_instance.details != finding.resource_details:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.details = finding.resource_details
|
||||
updated = True
|
||||
if resource_instance.partition != finding.partition:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.partition = finding.partition
|
||||
updated = True
|
||||
if group and (
|
||||
not resource_instance.groups
|
||||
or group not in resource_instance.groups
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.groups = (resource_instance.groups or []) + [
|
||||
group
|
||||
]
|
||||
@@ -950,6 +1077,7 @@ def _process_finding_micro_batch(
|
||||
muted_reason = mute_rules_cache[finding_uid]
|
||||
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
snapshot_failed_count(resource_uid)
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
@@ -1107,6 +1235,7 @@ def _process_finding_micro_batch(
|
||||
if r is None:
|
||||
continue
|
||||
# Manually bump updated_at since bulk_update bypasses auto_now.
|
||||
snapshot_resource_fields(uid, r)
|
||||
r.updated_at = now_utc
|
||||
resources_to_bulk_update.append(r)
|
||||
if resources_to_bulk_update:
|
||||
@@ -1128,6 +1257,7 @@ def _process_finding_micro_batch(
|
||||
# Successful execution: leave deadlock retry loop.
|
||||
break
|
||||
except (OperationalError, IntegrityError) as db_err:
|
||||
restore_attempt_caches()
|
||||
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
|
||||
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from io import StringIO
|
||||
@@ -15,13 +16,16 @@ from api.models import (
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceScanSummary,
|
||||
ResourceTag,
|
||||
ResourceTagMapping,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
from django.db import IntegrityError, OperationalError
|
||||
from django.db import IntegrityError, OperationalError, transaction
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.scan import (
|
||||
@@ -53,6 +57,12 @@ def noop_rls_transaction(*args, **kwargs):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_rls_transaction(*args, **kwargs):
|
||||
with transaction.atomic():
|
||||
yield
|
||||
|
||||
|
||||
class FakeFinding:
|
||||
def __init__(self, **attrs):
|
||||
self.metadata = attrs.pop("metadata", {})
|
||||
@@ -71,6 +81,32 @@ class FakeFinding:
|
||||
return self.metadata
|
||||
|
||||
|
||||
class CacheMissAfterPreResolve(MutableMapping):
|
||||
def __init__(self, missing_uid):
|
||||
self._cache = {}
|
||||
self.missing_uid = missing_uid
|
||||
|
||||
def __contains__(self, key):
|
||||
if key == self.missing_uid:
|
||||
return True
|
||||
return key in self._cache
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._cache[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._cache[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._cache[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cache)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPerformScan:
|
||||
def test_perform_prowler_scan_success(
|
||||
@@ -1055,8 +1091,12 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify findings are muted with correct reason
|
||||
fail_finding_db = Finding.objects.get(uid=finding_uid_1)
|
||||
pass_finding_db = Finding.objects.get(uid=finding_uid_2)
|
||||
fail_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_1
|
||||
)
|
||||
pass_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_2
|
||||
)
|
||||
|
||||
assert fail_finding_db.muted
|
||||
assert fail_finding_db.muted_reason == mute_rule_reason
|
||||
@@ -1067,7 +1107,9 @@ class TestPerformScan:
|
||||
assert pass_finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0 for muted FAIL finding
|
||||
resource_1 = Resource.objects.get(uid="resource_uid_1")
|
||||
resource_1 = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_1"
|
||||
)
|
||||
assert resource_1.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_with_inactive_mute_rules(
|
||||
@@ -1147,13 +1189,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify finding is NOT muted
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
assert finding_db.muted_at is None
|
||||
|
||||
# Verify failed_findings_count increments for FAIL finding
|
||||
resource = Resource.objects.get(uid="resource_uid_inactive")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_inactive"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
|
||||
@@ -1233,13 +1279,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify mutelist reason takes precedence
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == "Muted by mutelist"
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0
|
||||
resource = Resource.objects.get(uid="resource_both")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_both"
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_multiple_findings(
|
||||
@@ -1331,14 +1381,20 @@ class TestPerformScan:
|
||||
|
||||
# Verify all findings are muted with same reason
|
||||
for uid in finding_uids:
|
||||
finding_db = Finding.objects.get(uid=uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == mute_rule_reason
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify all resources have failed_findings_count = 0
|
||||
for i in range(len(finding_uids)):
|
||||
resource = Resource.objects.get(uid=f"resource_bulk_{i}")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=f"resource_bulk_{i}",
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_error_handling(
|
||||
@@ -1416,12 +1472,18 @@ class TestPerformScan:
|
||||
assert scan.state == StateChoices.COMPLETED
|
||||
|
||||
# Verify finding is not muted (mute_rules_cache was empty dict)
|
||||
finding_db = Finding.objects.get(uid="finding_error_handling")
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid="finding_error_handling",
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
|
||||
# Verify failed_findings_count increments
|
||||
resource = Resource.objects.get(uid="resource_error")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_error"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_muted_at_timestamp(
|
||||
@@ -1503,7 +1565,9 @@ class TestPerformScan:
|
||||
after_scan = datetime.now(UTC)
|
||||
|
||||
# Verify muted_at is within the scan time window
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_at is not None
|
||||
assert before_scan <= finding_db.muted_at <= after_scan
|
||||
@@ -1514,6 +1578,548 @@ class TestPerformScan:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcessFindingMicroBatch:
|
||||
def _process_one_finding_micro_batch(
|
||||
self,
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=None,
|
||||
resource_failed_findings_cache=None,
|
||||
):
|
||||
resource_cache = resource_cache if resource_cache is not None else {}
|
||||
resource_failed_findings_cache = (
|
||||
resource_failed_findings_cache
|
||||
if resource_failed_findings_cache is not None
|
||||
else {}
|
||||
)
|
||||
caches = {
|
||||
"resource_cache": resource_cache,
|
||||
"tag_cache": {},
|
||||
"last_status_cache": {},
|
||||
"resource_failed_findings_cache": resource_failed_findings_cache,
|
||||
"unique_resources": set(),
|
||||
"scan_resource_cache": set(),
|
||||
"mute_rules_cache": {},
|
||||
"scan_categories_cache": {},
|
||||
"scan_resource_groups_cache": {},
|
||||
"group_resources_cache": {},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding],
|
||||
scan,
|
||||
provider,
|
||||
caches["resource_cache"],
|
||||
caches["tag_cache"],
|
||||
caches["last_status_cache"],
|
||||
caches["resource_failed_findings_cache"],
|
||||
caches["unique_resources"],
|
||||
caches["scan_resource_cache"],
|
||||
caches["mute_rules_cache"],
|
||||
caches["scan_categories_cache"],
|
||||
caches["scan_resource_groups_cache"],
|
||||
caches["group_resources_cache"],
|
||||
)
|
||||
|
||||
return caches
|
||||
|
||||
def test_process_finding_micro_batch_fallback_creates_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unknown"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-create",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing analyzer",
|
||||
severity=Severity.medium,
|
||||
check_id="accessanalyzer_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="analyzer/unknown",
|
||||
region="us-east-1",
|
||||
service_name="accessanalyzer",
|
||||
resource_type="analyzer",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.provider_id == provider.id
|
||||
assert resource.region == finding.region
|
||||
assert resource.service == finding.service_name
|
||||
assert resource.type == finding.resource_type
|
||||
assert resource.name == finding.resource_name
|
||||
assert resource.groups == ["identity"]
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_existing_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:guardduty:us-east-1:123456789012:detector/unknown"
|
||||
existing_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="detector/unknown",
|
||||
region="us-east-1",
|
||||
service="guardduty",
|
||||
type="detector",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-existing",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing detector",
|
||||
severity=Severity.high,
|
||||
check_id="guardduty_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name=existing_resource.name,
|
||||
region=existing_resource.region,
|
||||
service_name=existing_resource.service,
|
||||
resource_type=existing_resource.type,
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
existing_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == existing_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_after_create_race(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unknown"
|
||||
raced_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service="securityhub",
|
||||
type="hub",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-failure",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, raced_resource]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
return_value=resource_filter_result,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("duplicate resource"),
|
||||
),
|
||||
):
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
raced_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert raced_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == raced_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_cache_miss_retry_drops_rolled_back_resource(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "generic-resource-cache-miss-retry"
|
||||
cached_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid="generic-cached-resource-retry",
|
||||
name="old-cached-resource",
|
||||
region="us-west-2",
|
||||
service="old-service",
|
||||
type="old-type",
|
||||
)
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-clean-resource-cache",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing resource",
|
||||
severity=Severity.high,
|
||||
check_id="generic_resource_check",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="generic-resource",
|
||||
region="us-east-1",
|
||||
service_name="generic-service",
|
||||
resource_type="generic-type",
|
||||
resource_tags={"team": "platform"},
|
||||
resource_metadata={"owner": "security"},
|
||||
resource_details={"id": "generic-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
cached_resource_finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-restores-dirty-resource",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="cached resource changed",
|
||||
severity=Severity.high,
|
||||
check_id="generic_cached_resource_check",
|
||||
resource_uid=cached_resource.uid,
|
||||
resource_name="new-cached-resource",
|
||||
region="eu-west-1",
|
||||
service_name="new-service",
|
||||
resource_type="new-type",
|
||||
resource_tags={},
|
||||
resource_metadata={"owner": "platform"},
|
||||
resource_details={"id": "cached-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
resource_cache = CacheMissAfterPreResolve(resource_uid)
|
||||
resource_cache[cached_resource.uid] = cached_resource
|
||||
tag_cache = {}
|
||||
resource_failed_findings_cache = {cached_resource.uid: 0}
|
||||
scan_resource_cache: set[tuple[str, str, str, str]] = set()
|
||||
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
group_resources_cache: dict[str, set] = {}
|
||||
original_bulk_create = ResourceFindingMapping.objects.bulk_create
|
||||
original_tag_mapping_bulk_create = ResourceTagMapping.objects.bulk_create
|
||||
mapping_bulk_create_calls = []
|
||||
tag_mapping_bulk_create_calls = []
|
||||
|
||||
def fail_once_then_bulk_create(objects, *args, **kwargs):
|
||||
mapping_bulk_create_calls.append([str(obj.resource_id) for obj in objects])
|
||||
if len(mapping_bulk_create_calls) == 1:
|
||||
raise IntegrityError("rollback after fallback resource creation")
|
||||
return original_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
def track_tag_mappings_bulk_create(objects, *args, **kwargs):
|
||||
tag_mapping_bulk_create_calls.append([str(obj.tag_id) for obj in objects])
|
||||
return original_tag_mapping_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 2),
|
||||
patch("tasks.jobs.scan.rls_transaction", new=atomic_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=atomic_rls_transaction),
|
||||
patch.object(
|
||||
ResourceTagMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=track_tag_mappings_bulk_create,
|
||||
),
|
||||
patch.object(
|
||||
ResourceFindingMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=fail_once_then_bulk_create,
|
||||
),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding, cached_resource_finding],
|
||||
scan,
|
||||
provider,
|
||||
resource_cache,
|
||||
tag_cache,
|
||||
{},
|
||||
resource_failed_findings_cache,
|
||||
set(),
|
||||
scan_resource_cache,
|
||||
{},
|
||||
scan_categories_cache,
|
||||
scan_resource_groups_cache,
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=resource_uid,
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
)
|
||||
cached_resource.refresh_from_db()
|
||||
|
||||
assert len(mapping_bulk_create_calls) == 2
|
||||
assert mapping_bulk_create_calls[0] != mapping_bulk_create_calls[1]
|
||||
assert len(tag_mapping_bulk_create_calls) == 2
|
||||
assert tag_mapping_bulk_create_calls[0] != tag_mapping_bulk_create_calls[1]
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=cached_resource_finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.name == cached_resource_finding.resource_name
|
||||
assert cached_resource.region == cached_resource_finding.region
|
||||
assert cached_resource.service == cached_resource_finding.service_name
|
||||
assert cached_resource.type == cached_resource_finding.resource_type
|
||||
assert resource_cache[resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[resource_uid] == 1
|
||||
assert resource_failed_findings_cache[cached_resource.uid] == 1
|
||||
assert scan_resource_cache == {
|
||||
(
|
||||
str(resource.id),
|
||||
finding.service_name,
|
||||
finding.region,
|
||||
finding.resource_type,
|
||||
),
|
||||
(
|
||||
str(cached_resource.id),
|
||||
cached_resource_finding.service_name,
|
||||
cached_resource_finding.region,
|
||||
cached_resource_finding.resource_type,
|
||||
),
|
||||
}
|
||||
assert (
|
||||
tag_cache[("team", "platform")].id
|
||||
== ResourceTag.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
key="team",
|
||||
value="platform",
|
||||
).id
|
||||
)
|
||||
assert scan_categories_cache == {
|
||||
("security", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert scan_resource_groups_cache == {
|
||||
("identity", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert group_resources_cache == {
|
||||
"identity": {resource_uid, cached_resource.uid}
|
||||
}
|
||||
|
||||
def test_process_finding_micro_batch_propagates_retryable_cache_miss_db_errors(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/retryable"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retryable-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/retryable",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=OperationalError("deadlock detected"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(OperationalError, match="deadlock detected"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_propagates_unrecovered_cache_miss_integrity_error(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unrecovered"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-unrecovered-integrity-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unrecovered",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
original_resource_filter = Resource.objects.filter
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, None]
|
||||
|
||||
def resource_filter_side_effect(*args, **kwargs):
|
||||
if kwargs.get("uid") == resource_uid:
|
||||
return resource_filter_result
|
||||
return original_resource_filter(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
side_effect=resource_filter_side_effect,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("constraint violation"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(IntegrityError, match="constraint violation"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_creates_records_and_updates_caches(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
@@ -1574,8 +2180,12 @@ class TestProcessFindingMicroBatch:
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
resource = Resource.objects.get(uid=finding.resource_uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=finding.resource_uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert created_finding.status == StatusChoices.PASS
|
||||
@@ -1603,7 +2213,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(resource.tags.values_list("key", "value")) == set(
|
||||
finding.resource_tags.items()
|
||||
)
|
||||
assert resource.findings.filter(uid=finding.uid).exists()
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[finding.resource_uid] == 0
|
||||
@@ -1692,7 +2304,9 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
existing_resource.refresh_from_db()
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.delta == Finding.DeltaChoices.CHANGED
|
||||
assert created_finding.status == StatusChoices.FAIL
|
||||
@@ -1726,7 +2340,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(existing_resource.tags.values_list("key", "value")) == {
|
||||
("team", "devsec")
|
||||
}
|
||||
assert existing_resource.findings.filter(uid=finding.uid).exists()
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].region == finding.region
|
||||
assert resource_cache[finding.resource_uid].service == finding.service_name
|
||||
@@ -1892,10 +2508,14 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
# Verify the long UID finding was NOT created
|
||||
assert not Finding.objects.filter(uid=long_uid).exists()
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=long_uid
|
||||
).exists()
|
||||
|
||||
# Verify the normal finding WAS created
|
||||
assert Finding.objects.filter(uid=normal_finding.uid).exists()
|
||||
assert Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=normal_finding.uid
|
||||
).exists()
|
||||
|
||||
# Verify logging was called for skipped finding
|
||||
assert mock_logger.warning.called
|
||||
@@ -2020,8 +2640,12 @@ class TestProcessFindingMicroBatch:
|
||||
"new_failed": 1,
|
||||
}
|
||||
|
||||
created_finding1 = Finding.objects.get(uid="finding-cat-1")
|
||||
created_finding2 = Finding.objects.get(uid="finding-cat-2")
|
||||
created_finding1 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-1"
|
||||
)
|
||||
created_finding2 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-2"
|
||||
)
|
||||
assert set(created_finding1.categories) == {"gen-ai", "security"}
|
||||
assert set(created_finding2.categories) == {"security", "iam"}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
/** Findings-only filter fields not shared with other views. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const FINDINGS_EXTRA_FIELD = {
|
||||
CHECK_ID: "check_id",
|
||||
CHECK_ID_IN: "check_id__in",
|
||||
DELTA_IN: "delta__in",
|
||||
SCAN_EXACT: "scan",
|
||||
SCAN_ID: "scan_id",
|
||||
|
||||
@@ -1,126 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
|
||||
const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
pollTaskUntilSettledMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: () => ({ error: "An error occurred" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/task/poll", () => ({
|
||||
pollTaskUntilSettled: pollTaskUntilSettledMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi.fn(),
|
||||
}));
|
||||
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pollJiraDispatchTask } from "./jira-dispatch";
|
||||
|
||||
describe("pollJiraDispatchTask", () => {
|
||||
describe("sendJiraDispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: "task-1",
|
||||
type: "tasks",
|
||||
attributes: { result: null },
|
||||
},
|
||||
}),
|
||||
{ status: 202 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the backend error when a completed task has failed Jira dispatches", async () => {
|
||||
it("should send grouped dispatch mode with multiple finding IDs", async () => {
|
||||
// Given / When
|
||||
await sendJiraDispatch({
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
filter: "finding_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "grouped",
|
||||
});
|
||||
|
||||
// Then
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2",
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
data: {
|
||||
attributes: {
|
||||
dispatch_mode: "grouped",
|
||||
issue_type: "Task",
|
||||
project_key: "SEC",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should send grouped dispatch mode with a finding group check ID", async () => {
|
||||
// Given / When
|
||||
await sendJiraDispatch({
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["s3_bucket_public_access"],
|
||||
filter: "check_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "grouped",
|
||||
});
|
||||
|
||||
// Then
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access",
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
data: { attributes: { dispatch_mode: "grouped" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
created_count: 2,
|
||||
failed_count: 1,
|
||||
error: "Jira project requires custom fields: Team is required",
|
||||
failed_finding_ids: ["finding-3"],
|
||||
error: "Jira rejected one Finding.",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Jira project requires custom fields: Team is required",
|
||||
success: true,
|
||||
message: "2 Jira issues were created or updated successfully.",
|
||||
warning:
|
||||
"Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.",
|
||||
failedFindingIds: ["finding-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a fallback error when a completed task has failures without an error", async () => {
|
||||
it("should include updated issues in partial failure summaries", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 1,
|
||||
},
|
||||
result: { created_count: 0, updated_count: 2, failed_count: 1 },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create Jira issue.",
|
||||
success: true,
|
||||
message: "2 Jira issues were created or updated successfully.",
|
||||
warning:
|
||||
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
|
||||
it("should fail completed task polling when grouped dispatch reports failed groups", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 3,
|
||||
},
|
||||
result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create 3 Jira issues.",
|
||||
success: true,
|
||||
message: "Finding successfully sent to Jira!",
|
||||
warning:
|
||||
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should surface task failure result errors", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "failed",
|
||||
result: {
|
||||
error: "Jira credentials are invalid.",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Jira credentials are invalid.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return success when a completed task has no failures", async () => {
|
||||
it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 1,
|
||||
failed_count: 0,
|
||||
},
|
||||
result: { created_count: 1, failed_groups: [] },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
@@ -129,24 +170,68 @@ describe("pollJiraDispatchTask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a fallback error when no Jira issue was created", async () => {
|
||||
it("should succeed completed task polling with grouped created and updated issue result shape", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
created_count: 1,
|
||||
updated_count: 1,
|
||||
failed_count: 0,
|
||||
created_issues: [{ key: "SEC-1" }],
|
||||
updated_issues: [{ key: "SEC-2" }],
|
||||
failed_groups: [],
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", {
|
||||
maxAttempts: 5,
|
||||
delayMs: 2000,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: "2 Jira issues were created or updated successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail completed task polling when Jira dispatch completed as a no-op", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: { created_count: 0, updated_count: 0, failed_count: 0 },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create Jira issue.",
|
||||
error: "Jira dispatch completed but did not create or update any issues.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail completed task polling when Jira dispatch has no result payload", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Jira dispatch completed but did not create or update any issues.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
JiraDispatchMode,
|
||||
JiraDispatchRequest,
|
||||
JiraDispatchResponse,
|
||||
JiraDispatchTarget,
|
||||
JiraDispatchTaskResult,
|
||||
} from "@/types/integrations";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
interface JiraDispatchInput {
|
||||
integrationId: string;
|
||||
targetIds: string[];
|
||||
filter: JiraDispatchTarget;
|
||||
projectKey: string;
|
||||
issueType: string;
|
||||
dispatchMode?: JiraDispatchMode;
|
||||
}
|
||||
|
||||
export const getJiraIssueTypes = async (
|
||||
integrationId: string,
|
||||
@@ -87,14 +101,37 @@ export const sendFindingToJira = async (
|
||||
): Promise<
|
||||
| { success: true; taskId: string; message: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
return sendJiraDispatch({
|
||||
integrationId,
|
||||
targetIds: [findingId],
|
||||
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
projectKey,
|
||||
issueType,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendJiraDispatch = async ({
|
||||
integrationId,
|
||||
targetIds,
|
||||
filter,
|
||||
projectKey,
|
||||
issueType,
|
||||
dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
}: JiraDispatchInput): Promise<
|
||||
| { success: true; taskId: string; message: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
|
||||
);
|
||||
|
||||
// Single finding: use direct filter without array notation
|
||||
url.searchParams.append("filter[finding_id]", findingId);
|
||||
if (targetIds.length === 1) {
|
||||
url.searchParams.append(`filter[${filter}]`, targetIds[0]);
|
||||
} else {
|
||||
url.searchParams.append(`filter[${filter}__in]`, targetIds.join(","));
|
||||
}
|
||||
|
||||
const payload: JiraDispatchRequest = {
|
||||
data: {
|
||||
@@ -102,6 +139,7 @@ export const sendFindingToJira = async (
|
||||
attributes: {
|
||||
project_key: projectKey,
|
||||
issue_type: issueType,
|
||||
dispatch_mode: dispatchMode,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -145,38 +183,18 @@ export const sendFindingToJira = async (
|
||||
export const pollJiraDispatchTask = async (
|
||||
taskId: string,
|
||||
): Promise<
|
||||
{ success: true; message: string } | { success: false; error: string }
|
||||
| { success: true; message: string; warning?: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
const res = await pollTaskUntilSettled(taskId, {
|
||||
maxAttempts: 30,
|
||||
maxAttempts: 5,
|
||||
delayMs: 2000,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { success: false, error: res.error };
|
||||
}
|
||||
const { state, result } = res;
|
||||
type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"];
|
||||
const jiraResult = result as JiraTaskResult | undefined;
|
||||
|
||||
if (state === "completed") {
|
||||
const createdCount = jiraResult?.created_count ?? 0;
|
||||
const failedCount = jiraResult?.failed_count ?? 0;
|
||||
if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
|
||||
return { success: true, message: "Finding successfully sent to Jira!" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
jiraResult?.error ||
|
||||
(failedCount > 1
|
||||
? `Failed to create ${failedCount} Jira issues.`
|
||||
: "Failed to create Jira issue."),
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "failed") {
|
||||
return { success: false, error: jiraResult?.error || "Task failed." };
|
||||
}
|
||||
|
||||
return { success: false, error: `Unknown task state: ${state}` };
|
||||
return evaluateJiraDispatchTask(
|
||||
res.state,
|
||||
res.result as JiraDispatchTaskResult | undefined,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,4 +44,17 @@ describe("findings page", () => {
|
||||
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
|
||||
expect(source).toContain("applyDefaultMutedFilter");
|
||||
});
|
||||
|
||||
it("loads finding groups as selectable Finding Group filter options", () => {
|
||||
expect(source).toContain("fetchFindingGroupFilterOptions");
|
||||
expect(source).toContain("FINDING_GROUP_FILTER_OPTION_PAGE_SIZE");
|
||||
expect(source).toContain("checkOptions={checkOptions}");
|
||||
});
|
||||
|
||||
it("excludes Finding Group's own filters while loading all option pages", () => {
|
||||
expect(source).toContain("excludeFindingGroupOwnFilters");
|
||||
expect(source).toContain('"filter[check_id__in]"');
|
||||
expect(source).toContain("if (page >= totalPages) break");
|
||||
expect(source).toContain("page += 1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,60 @@ import { isCloud } from "@/lib/shared/env";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
|
||||
const FINDING_GROUP_OWN_FILTER_KEYS = [
|
||||
"filter[check_id]",
|
||||
"filter[check_id__in]",
|
||||
] as const;
|
||||
|
||||
type FindingGroupFilterFetcher = typeof getFindingGroups;
|
||||
|
||||
function excludeFindingGroupOwnFilters(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters).filter(
|
||||
([key]) =>
|
||||
!FINDING_GROUP_OWN_FILTER_KEYS.includes(
|
||||
key as (typeof FINDING_GROUP_OWN_FILTER_KEYS)[number],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function getFindingGroupFilterOptions({
|
||||
fetchFindingGroups,
|
||||
filters,
|
||||
}: {
|
||||
fetchFindingGroups: FindingGroupFilterFetcher;
|
||||
filters: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const optionFilters = excludeFindingGroupOwnFilters(filters);
|
||||
const options = new Map<string, { checkId: string; checkTitle: string }>();
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFindingGroups({
|
||||
filters: optionFilters,
|
||||
page,
|
||||
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
|
||||
});
|
||||
|
||||
for (const group of adaptFindingGroupsResponse(response)) {
|
||||
options.set(group.checkId, {
|
||||
checkId: group.checkId,
|
||||
checkTitle: group.checkTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const totalPages = response?.meta?.pagination?.pages ?? page;
|
||||
if (page >= totalPages) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return Array.from(options.values());
|
||||
}
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -68,6 +122,13 @@ export default async function Findings({
|
||||
metadataInfoData?.data?.attributes?.resource_types || [];
|
||||
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
const fetchFindingGroupFilterOptions = hasHistoricalData
|
||||
? getFindingGroups
|
||||
: getLatestFindingGroups;
|
||||
const checkOptions = await getFindingGroupFilterOptions({
|
||||
fetchFindingGroups: fetchFindingGroupFilterOptions,
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
|
||||
const completedScans = scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
@@ -110,6 +171,7 @@ export default async function Findings({
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
checkOptions={checkOptions}
|
||||
trailingControls={
|
||||
<SeedFromFindingsButton
|
||||
filterBag={filters}
|
||||
@@ -145,6 +207,10 @@ const SSRDataTable = async ({
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const expandedCheckIdParam = searchParams.expandedCheckId;
|
||||
const expandedCheckId = Array.isArray(expandedCheckIdParam)
|
||||
? expandedCheckIdParam[0]
|
||||
: expandedCheckIdParam;
|
||||
|
||||
const { encodedSort } = extractSortAndKey(searchParams);
|
||||
const hasHistoricalData = hasDateOrScanFilter(filters);
|
||||
@@ -178,6 +244,7 @@ const SSRDataTable = async ({
|
||||
metadata={findingGroupsData?.meta}
|
||||
resolvedFilters={filters}
|
||||
hasHistoricalData={hasHistoricalData}
|
||||
expandedCheckId={expandedCheckId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts
|
||||
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildFindingGroupFilterOption,
|
||||
buildFindingsFilterChips,
|
||||
type FindingCheckFilterOption,
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
|
||||
@@ -42,6 +44,7 @@ interface FindingsFiltersProps {
|
||||
uniqueResourceTypes: string[];
|
||||
uniqueCategories: string[];
|
||||
uniqueGroups: string[];
|
||||
checkOptions?: FindingCheckFilterOption[];
|
||||
trailingControls?: ReactNode;
|
||||
variant?: "default" | "alerts-edit";
|
||||
}
|
||||
@@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
|
||||
const FILTER_CONTROL_COLUMN_CLASS =
|
||||
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
|
||||
const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
||||
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
|
||||
|
||||
export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
@@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({
|
||||
uniqueResourceTypes,
|
||||
uniqueCategories,
|
||||
uniqueGroups,
|
||||
checkOptions = [],
|
||||
trailingControls,
|
||||
appliedFilters,
|
||||
pendingFilters,
|
||||
@@ -102,6 +107,18 @@ export const FindingsFilterBatchControls = ({
|
||||
}: FindingsFilterBatchControlsProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isAlertsEdit = variant === "alerts-edit";
|
||||
const checkTitles = Object.fromEntries(
|
||||
checkOptions.map(({ checkId, checkTitle }) => [
|
||||
checkId,
|
||||
checkTitle || checkId,
|
||||
]),
|
||||
);
|
||||
const findingGroupFilterOption = buildFindingGroupFilterOption({
|
||||
checkOptions,
|
||||
selectedCheckIds: getFilterValue("filter[check_id]"),
|
||||
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
|
||||
checkTitles,
|
||||
});
|
||||
|
||||
const customFilters = [
|
||||
...filterFindings
|
||||
@@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({
|
||||
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
|
||||
providers,
|
||||
scans: scanDetails,
|
||||
checkTitles,
|
||||
}),
|
||||
})),
|
||||
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
|
||||
{
|
||||
key: FILTER_FIELD.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 3,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
key: FILTER_FIELD.SERVICE,
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 4,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: FILTER_FIELD.RESOURCE_TYPE,
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
index: 9,
|
||||
},
|
||||
{
|
||||
key: FILTER_FIELD.CATEGORY,
|
||||
labelCheckboxGroup: "Category",
|
||||
values: uniqueCategories,
|
||||
labelFormatter: getCategoryLabel,
|
||||
index: 5,
|
||||
index: 6,
|
||||
},
|
||||
{
|
||||
key: FILTER_FIELD.RESOURCE_GROUPS,
|
||||
labelCheckboxGroup: "Resource Group",
|
||||
values: uniqueGroups,
|
||||
labelFormatter: getGroupLabel,
|
||||
index: 6,
|
||||
index: 7,
|
||||
},
|
||||
...(isAlertsEdit
|
||||
? []
|
||||
@@ -164,7 +183,7 @@ export const FindingsFilterBatchControls = ({
|
||||
scans: scanDetails,
|
||||
},
|
||||
),
|
||||
index: 7,
|
||||
index: 8,
|
||||
},
|
||||
]),
|
||||
];
|
||||
@@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
checkTitles,
|
||||
},
|
||||
);
|
||||
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
|
||||
@@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
checkTitles,
|
||||
},
|
||||
);
|
||||
const appliedCount = countVisibleFilterKeys(appliedFilters);
|
||||
@@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
|
||||
getFilterValue,
|
||||
} = useFilterBatch({
|
||||
defaultParams: { "filter[muted]": "false" },
|
||||
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import {
|
||||
buildFindingGroupFilterOption,
|
||||
buildFindingsFilterChips,
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
@@ -164,6 +165,30 @@ describe("getFindingsFilterDisplayValue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the finding group title for check_id filters when available", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[check_id]",
|
||||
"teams_external_users_can_join",
|
||||
{
|
||||
checkTitles: {
|
||||
teams_external_users_can_join:
|
||||
"External Teams users can join meetings",
|
||||
},
|
||||
},
|
||||
),
|
||||
).toBe("External Teams users can join meetings");
|
||||
});
|
||||
|
||||
it("keeps the check id when no finding group title is available", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[check_id]",
|
||||
"teams_external_users_can_join",
|
||||
),
|
||||
).toBe("teams_external_users_can_join");
|
||||
});
|
||||
|
||||
it("uses the provider display name regardless of account alias/uid", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
|
||||
@@ -298,6 +323,45 @@ describe("buildFindingsFilterChips", () => {
|
||||
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
|
||||
});
|
||||
|
||||
it("renders filter[check_id] as a first-class Finding Group chip", () => {
|
||||
// Given - exact deep-link params from the grouped findings page.
|
||||
const chips = buildFindingsFilterChips(
|
||||
{
|
||||
"filter[check_id]": ["teams_external_users_can_join"],
|
||||
},
|
||||
{
|
||||
checkTitles: {
|
||||
teams_external_users_can_join:
|
||||
"External Teams users can join meetings",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(chips).toEqual([
|
||||
{
|
||||
key: "filter[check_id]",
|
||||
label: "Finding Group",
|
||||
value: "teams_external_users_can_join",
|
||||
displayValue: "External Teams users can join meetings",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders filter[check_id__in] with the Finding Group chip label", () => {
|
||||
const chips = buildFindingsFilterChips({
|
||||
"filter[check_id__in]": ["teams_external_users_can_join"],
|
||||
});
|
||||
|
||||
expect(chips).toEqual([
|
||||
{
|
||||
key: "filter[check_id__in]",
|
||||
label: "Finding Group",
|
||||
value: "teams_external_users_can_join",
|
||||
displayValue: "teams_external_users_can_join",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips muted filters because the table toolbar owns that control", () => {
|
||||
const chips = buildFindingsFilterChips({
|
||||
"filter[muted]": ["include"],
|
||||
@@ -324,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFindingGroupFilterOption", () => {
|
||||
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
|
||||
// Given
|
||||
const filter = buildFindingGroupFilterOption({
|
||||
checkOptions: [
|
||||
{
|
||||
checkId: "teams_external_users_can_join",
|
||||
checkTitle: "External Teams users can join meetings",
|
||||
},
|
||||
],
|
||||
selectedCheckIds: ["s3_bucket_public_access"],
|
||||
selectedCheckIdsIn: ["teams_external_users_can_join"],
|
||||
checkTitles: {
|
||||
teams_external_users_can_join: "External Teams users can join meetings",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(filter).toMatchObject({
|
||||
key: "check_id__in",
|
||||
labelCheckboxGroup: "Finding Group",
|
||||
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
|
||||
index: 3,
|
||||
});
|
||||
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
|
||||
"External Teams users can join meetings",
|
||||
);
|
||||
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
|
||||
"s3_bucket_public_access",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
|
||||
expect(
|
||||
buildFindingGroupFilterOption({
|
||||
checkOptions: [],
|
||||
selectedCheckIds: [],
|
||||
selectedCheckIdsIn: [],
|
||||
checkTitles: {},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,21 @@ import {
|
||||
} from "@/lib/helper-filters";
|
||||
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import type { FilterOption } from "@/types/filters";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
|
||||
export interface FindingCheckFilterOption {
|
||||
checkId: string;
|
||||
checkTitle?: string;
|
||||
}
|
||||
|
||||
interface GetFindingsFilterDisplayValueOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
providerGroups?: ProviderGroup[];
|
||||
checkTitles?: Record<string, string>;
|
||||
}
|
||||
|
||||
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
|
||||
@@ -64,6 +71,12 @@ export function getFindingsFilterDisplayValue(
|
||||
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
|
||||
return getScanDisplayValue(value, options.scans || []);
|
||||
}
|
||||
if (
|
||||
filterKey === "filter[check_id]" ||
|
||||
filterKey === "filter[check_id__in]"
|
||||
) {
|
||||
return options.checkTitles?.[value] || value;
|
||||
}
|
||||
if (filterKey === "filter[severity__in]") {
|
||||
return (
|
||||
SEVERITY_DISPLAY_NAMES[
|
||||
@@ -100,6 +113,43 @@ export function getFindingsFilterDisplayValue(
|
||||
return formatLabel(value);
|
||||
}
|
||||
|
||||
function uniqueNonEmptyValues(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function buildFindingGroupFilterOption({
|
||||
checkOptions,
|
||||
selectedCheckIds,
|
||||
selectedCheckIdsIn,
|
||||
checkTitles,
|
||||
}: {
|
||||
checkOptions: FindingCheckFilterOption[];
|
||||
selectedCheckIds: string[];
|
||||
selectedCheckIdsIn: string[];
|
||||
checkTitles: Record<string, string>;
|
||||
}): FilterOption | null {
|
||||
const values = uniqueNonEmptyValues([
|
||||
...checkOptions.map((option) => option.checkId),
|
||||
...selectedCheckIds,
|
||||
...selectedCheckIdsIn,
|
||||
]);
|
||||
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: "check_id__in",
|
||||
labelCheckboxGroup: "Finding Group",
|
||||
values,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue("filter[check_id]", value, {
|
||||
checkTitles,
|
||||
}),
|
||||
index: 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
|
||||
* Used to render chips in the FilterSummaryStrip.
|
||||
@@ -108,6 +158,8 @@ export function getFindingsFilterDisplayValue(
|
||||
* label is missing.
|
||||
*/
|
||||
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
|
||||
"filter[check_id]": "Finding Group",
|
||||
"filter[check_id__in]": "Finding Group",
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[provider_groups__in]": "Provider Group",
|
||||
@@ -134,6 +186,7 @@ interface BuildFindingsFilterChipsOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
providerGroups?: ProviderGroup[];
|
||||
checkTitles?: Record<string, string>;
|
||||
includeMuted?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks to avoid deep dependency chains
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -42,6 +45,7 @@ function deferredPromise<T>() {
|
||||
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
@@ -199,4 +203,109 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should route Send to Jira through the action chooser without opening mute", async () => {
|
||||
// Given
|
||||
const onSendToJira = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={1}
|
||||
selectedFindingIds={["finding-1"]}
|
||||
canSendToJira
|
||||
onSendToJira={onSendToJira}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "1 selected" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
expect(onSendToJira).toHaveBeenCalledTimes(1);
|
||||
const modalCalls = MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[{ isOpen?: boolean }]
|
||||
>;
|
||||
expect(modalCalls.some(([props]) => props.isOpen === true)).toBe(false);
|
||||
});
|
||||
|
||||
it("should render custom mixed-selection action labels", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={2}
|
||||
selectedFindingIds={["group-1", "finding-1"]}
|
||||
label="1 Group and 1 Finding selected"
|
||||
muteLabel="Mute 1 Group and 1 Finding"
|
||||
sendToJiraLabel="Send 1 Group and 1 Finding to Jira"
|
||||
showSendToJira
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "1 Group and 1 Finding selected",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("menuitem", {
|
||||
name: "Mute 1 Group and 1 Finding",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("menuitem", {
|
||||
name: "Send 1 Group and 1 Finding to Jira",
|
||||
}),
|
||||
).toHaveTextContent("Send 1 Group and 1 Finding to Jira");
|
||||
});
|
||||
|
||||
it("should show the Cloud Jira tooltip and open the upgrade modal", async () => {
|
||||
// Given
|
||||
const onSendToJira = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={1}
|
||||
selectedFindingIds={["finding-1"]}
|
||||
showSendToJira
|
||||
canSendToJira={false}
|
||||
onSendToJira={onSendToJira}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "1 selected" }));
|
||||
const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" });
|
||||
|
||||
// Then
|
||||
expect(jiraAction).toBeVisible();
|
||||
expect(jiraAction).not.toHaveAttribute("aria-disabled");
|
||||
expect(
|
||||
within(jiraAction).queryByText("Available only in Prowler Cloud"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.hover(jiraAction);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"Available only in Prowler Cloud",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(jiraAction);
|
||||
|
||||
// Then
|
||||
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
|
||||
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
|
||||
);
|
||||
expect(onSendToJira).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { VolumeX } from "lucide-react";
|
||||
import { Ellipsis, VolumeX } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown/action-dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { MuteFindingsModal } from "./mute-findings-modal";
|
||||
|
||||
@@ -19,6 +27,16 @@ interface FloatingMuteButtonProps {
|
||||
isBulkOperation?: boolean;
|
||||
/** Custom button label. Defaults to "Mute ({selectedCount})" */
|
||||
label?: string;
|
||||
/** Custom mute action label. Defaults to "Mute". */
|
||||
muteLabel?: string;
|
||||
/** Opens the Jira flow for the current selection. */
|
||||
onSendToJira?: () => void;
|
||||
/** Whether the Jira action is available for the current selection. */
|
||||
canSendToJira?: boolean;
|
||||
/** Whether the Jira action should be displayed in the action menu. */
|
||||
showSendToJira?: boolean;
|
||||
/** Custom Jira action label. Defaults to "Send to Jira". */
|
||||
sendToJiraLabel?: string;
|
||||
}
|
||||
|
||||
export function FloatingMuteButton({
|
||||
@@ -28,6 +46,11 @@ export function FloatingMuteButton({
|
||||
onBeforeOpen,
|
||||
isBulkOperation = false,
|
||||
label,
|
||||
muteLabel = "Mute",
|
||||
onSendToJira,
|
||||
canSendToJira = false,
|
||||
showSendToJira = canSendToJira,
|
||||
sendToJiraLabel = "Send to Jira",
|
||||
}: FloatingMuteButtonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
@@ -36,6 +59,9 @@ export function FloatingMuteButton({
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
const handleModalOpenChange = (
|
||||
nextOpen: boolean | ((previousOpen: boolean) => boolean),
|
||||
@@ -51,7 +77,7 @@ export function FloatingMuteButton({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
const handleMuteClick = async () => {
|
||||
if (onBeforeOpen) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
@@ -79,6 +105,15 @@ export function FloatingMuteButton({
|
||||
}
|
||||
};
|
||||
|
||||
const handleJiraClick = () => {
|
||||
if (!canSendToJira) {
|
||||
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
onSendToJira?.();
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setResolvedIds([]);
|
||||
onComplete?.();
|
||||
@@ -103,20 +138,53 @@ export function FloatingMuteButton({
|
||||
with the content. */}
|
||||
{typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isResolving}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 flex gap-2 duration-300">
|
||||
<div className="shadow-lg">
|
||||
{showSendToJira ? (
|
||||
<ActionDropdown
|
||||
ariaLabel="Open selection actions"
|
||||
trigger={
|
||||
<Button disabled={isResolving} size="lg">
|
||||
{isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<Ellipsis className="size-5" />
|
||||
)}
|
||||
{label ?? `${selectedCount} selected`}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<VolumeX />}
|
||||
label={muteLabel}
|
||||
aria-label={muteLabel}
|
||||
onSelect={() => void handleMuteClick()}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label={sendToJiraLabel}
|
||||
tooltip={
|
||||
!canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
|
||||
}
|
||||
aria-label={sendToJiraLabel}
|
||||
onSelect={handleJiraClick}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
<Button
|
||||
onClick={() => void handleMuteClick()}
|
||||
disabled={isResolving}
|
||||
size="lg"
|
||||
>
|
||||
{isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
Mute ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
{label ?? `Mute (${selectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WatchedTask } from "@/store/task-watcher/store";
|
||||
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
|
||||
|
||||
import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler";
|
||||
|
||||
interface ToastActionMockProps extends ComponentProps<"button"> {
|
||||
altText: string;
|
||||
}
|
||||
|
||||
const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted(
|
||||
() => ({
|
||||
sendJiraDispatchMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
trackAndPollTaskMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/actions/integrations/jira-dispatch", () => ({
|
||||
sendJiraDispatch: sendJiraDispatchMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast", () => ({
|
||||
toast: toastMock,
|
||||
ToastAction: ({
|
||||
altText: _altText,
|
||||
children,
|
||||
...props
|
||||
}: ToastActionMockProps) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
trackAndPollTask: trackAndPollTaskMock,
|
||||
}));
|
||||
|
||||
const buildTask = (result: unknown): WatchedTask => ({
|
||||
taskId: "task-1",
|
||||
kind: "jira-dispatch",
|
||||
status: "ready",
|
||||
startedAt: Date.now(),
|
||||
meta: {
|
||||
integrationId: "jira-1",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
result,
|
||||
});
|
||||
|
||||
describe("jiraDispatchTaskHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendJiraDispatchMock.mockResolvedValue({
|
||||
success: true,
|
||||
taskId: "retry-task",
|
||||
message: "Started",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the completed Jira result after a persisted task resumes", () => {
|
||||
// Given
|
||||
const task = buildTask({ created_count: 2, failed_count: 0 });
|
||||
|
||||
// When
|
||||
jiraDispatchTaskHandler.onReady(task);
|
||||
|
||||
// Then
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success!",
|
||||
description: "2 Jira issues were created or updated successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries only failed Findings from a resumed partial task", async () => {
|
||||
// Given
|
||||
const task = buildTask({
|
||||
created_count: 1,
|
||||
failed_count: 2,
|
||||
failed_finding_ids: ["finding-2", "finding-3"],
|
||||
error: "Two Jira issues failed.",
|
||||
});
|
||||
jiraDispatchTaskHandler.onReady(task);
|
||||
const partialToast = toastMock.mock.calls.at(-1)?.[0];
|
||||
|
||||
// When
|
||||
await partialToast.action.props.onClick();
|
||||
|
||||
// Then
|
||||
expect(sendJiraDispatchMock).toHaveBeenCalledWith({
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["finding-2", "finding-3"],
|
||||
filter: "finding_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "individual",
|
||||
});
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
|
||||
taskId: "retry-task",
|
||||
kind: "jira-dispatch",
|
||||
meta: {
|
||||
...task.meta,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces task watcher errors without offering an unsafe retry", () => {
|
||||
// Given
|
||||
const task = {
|
||||
...buildTask(undefined),
|
||||
status: "error",
|
||||
error: "Tracking the task failed unexpectedly. Try again later.",
|
||||
} as WatchedTask;
|
||||
|
||||
// When
|
||||
jiraDispatchTaskHandler.onError(task);
|
||||
|
||||
// Then
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Jira dispatch failed",
|
||||
description: "Tracking the task failed unexpectedly. Try again later.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch";
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import {
|
||||
buildJiraDispatchTaskMeta,
|
||||
parseJiraDispatchTaskMeta,
|
||||
} from "@/lib/jira-dispatch-task";
|
||||
import {
|
||||
type TaskKindHandler,
|
||||
trackAndPollTask,
|
||||
type WatchedTask,
|
||||
} from "@/store/task-watcher/store";
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
JIRA_DISPATCH_TARGET,
|
||||
JIRA_DISPATCH_TASK_KIND,
|
||||
type JiraDispatchTaskResult,
|
||||
} from "@/types/integrations";
|
||||
|
||||
const retryFailedFindings = async (
|
||||
task: WatchedTask,
|
||||
failedFindingIds: string[],
|
||||
): Promise<void> => {
|
||||
const meta = parseJiraDispatchTaskMeta(task);
|
||||
if (!meta) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Jira retry failed",
|
||||
description: "The original Jira dispatch configuration is unavailable.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendJiraDispatch({
|
||||
integrationId: meta.integrationId,
|
||||
targetIds: failedFindingIds,
|
||||
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
projectKey: meta.projectKey,
|
||||
issueType: meta.issueType,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Jira retry failed",
|
||||
description: response.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Retry started",
|
||||
description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`,
|
||||
});
|
||||
|
||||
await trackAndPollTask<JiraDispatchTaskResult>({
|
||||
taskId: response.taskId,
|
||||
kind: JIRA_DISPATCH_TASK_KIND,
|
||||
meta: buildJiraDispatchTaskMeta({
|
||||
...meta,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Jira retry failed",
|
||||
description: "The retry could not be started. Try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) =>
|
||||
failedFindingIds?.length ? (
|
||||
<ToastAction
|
||||
altText="Retry failed Findings"
|
||||
onClick={() => retryFailedFindings(task, failedFindingIds)}
|
||||
>
|
||||
Retry failed
|
||||
</ToastAction>
|
||||
) : undefined;
|
||||
|
||||
export const jiraDispatchTaskHandler: TaskKindHandler = {
|
||||
onReady: (task) => {
|
||||
const outcome = evaluateJiraDispatchTask(
|
||||
"completed",
|
||||
task.result as JiraDispatchTaskResult | undefined,
|
||||
);
|
||||
|
||||
if (!outcome.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Jira dispatch failed",
|
||||
description: outcome.error,
|
||||
action: buildRetryAction(task, outcome.failedFindingIds),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: outcome.warning ? "Jira dispatch partially completed" : "Success!",
|
||||
description: outcome.warning ?? outcome.message,
|
||||
action: buildRetryAction(task, outcome.failedFindingIds),
|
||||
});
|
||||
},
|
||||
onError: (task) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Jira dispatch failed",
|
||||
description: task.error || "The Jira dispatch task failed unexpectedly.",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildJiraDispatchChoiceCopy,
|
||||
JIRA_SELECTION_KIND,
|
||||
} from "./send-to-jira-modal-copy";
|
||||
|
||||
describe("buildJiraDispatchChoiceCopy", () => {
|
||||
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
|
||||
expect(
|
||||
buildJiraDispatchChoiceCopy({
|
||||
selectedCount: 2,
|
||||
isSelectedFindingGroupFlow: true,
|
||||
}),
|
||||
).toEqual({
|
||||
description:
|
||||
"Create Jira issue(s) for 2 selected Findings from this Finding Group.",
|
||||
groupedTitle:
|
||||
"Create one Jira issue for all selected Findings in this Finding Group",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected Finding from this Finding Group.",
|
||||
individualHelp:
|
||||
"Use this when each selected Finding should be tracked independently.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves resource copy for resource-based grouped Jira choice", () => {
|
||||
expect(
|
||||
buildJiraDispatchChoiceCopy({
|
||||
selectedCount: 2,
|
||||
isSelectedFindingGroupFlow: false,
|
||||
}),
|
||||
).toEqual({
|
||||
description:
|
||||
"Create Jira issue(s) for 2 selected affected failing resources.",
|
||||
groupedTitle:
|
||||
"Create one Jira issue for all selected affected failing resources",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected resource from this finding group.",
|
||||
individualHelp:
|
||||
"Use this when each selected resource should be tracked independently.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses neutral Findings copy outside a single Finding Group", () => {
|
||||
expect(
|
||||
buildJiraDispatchChoiceCopy({
|
||||
selectedCount: 2,
|
||||
isSelectedFindingGroupFlow: false,
|
||||
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
|
||||
}),
|
||||
).toEqual({
|
||||
description: "Create Jira issue(s) for 2 selected Findings.",
|
||||
groupedTitle: "Create one Jira issue for all selected Findings",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected Finding.",
|
||||
individualHelp:
|
||||
"Use this when each selected Finding should be tracked independently.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export const JIRA_SELECTION_KIND = {
|
||||
FINDINGS: "findings",
|
||||
RESOURCES: "resources",
|
||||
} as const;
|
||||
|
||||
type JiraSelectionKind =
|
||||
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
|
||||
|
||||
interface JiraDispatchChoiceCopyParams {
|
||||
selectedCount: number;
|
||||
isSelectedFindingGroupFlow: boolean;
|
||||
selectionKind?: JiraSelectionKind;
|
||||
}
|
||||
|
||||
interface JiraDispatchChoiceCopy {
|
||||
description: string;
|
||||
groupedTitle: string;
|
||||
groupedHelp: string;
|
||||
individualHelp: string;
|
||||
}
|
||||
|
||||
export const buildJiraDispatchChoiceCopy = ({
|
||||
selectedCount,
|
||||
isSelectedFindingGroupFlow,
|
||||
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
|
||||
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
|
||||
if (isSelectedFindingGroupFlow) {
|
||||
return {
|
||||
description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`,
|
||||
groupedTitle:
|
||||
"Create one Jira issue for all selected Findings in this Finding Group",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected Finding from this Finding Group.",
|
||||
individualHelp:
|
||||
"Use this when each selected Finding should be tracked independently.",
|
||||
};
|
||||
}
|
||||
|
||||
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
|
||||
return {
|
||||
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
|
||||
groupedTitle: "Create one Jira issue for all selected Findings",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected Finding.",
|
||||
individualHelp:
|
||||
"Use this when each selected Finding should be tracked independently.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`,
|
||||
groupedTitle:
|
||||
"Create one Jira issue for all selected affected failing resources",
|
||||
groupedHelp:
|
||||
"Recommended. The issue will include every selected resource from this finding group.",
|
||||
individualHelp:
|
||||
"Use this when each selected resource should be tracked independently.",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,748 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createJiraBatchSelection,
|
||||
createJiraTargetSelection,
|
||||
} from "@/lib/jira-dispatch-selection";
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
JIRA_DISPATCH_TARGET,
|
||||
type JiraDispatchTarget,
|
||||
} from "@/types/integrations";
|
||||
|
||||
import { SendToJiraModal } from "./send-to-jira-modal";
|
||||
|
||||
const targetSelection = (targetIds: string[], targetType: JiraDispatchTarget) =>
|
||||
createJiraTargetSelection(targetIds, targetType)!;
|
||||
|
||||
const batchSelection = (
|
||||
batches: Parameters<typeof createJiraBatchSelection>[0],
|
||||
) => createJiraBatchSelection(batches)!;
|
||||
|
||||
const {
|
||||
getJiraIntegrationsMock,
|
||||
getJiraIssueTypesMock,
|
||||
sendFindingToJiraMock,
|
||||
sendJiraDispatchMock,
|
||||
trackAndPollTaskMock,
|
||||
toastMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getJiraIntegrationsMock: vi.fn(),
|
||||
getJiraIssueTypesMock: vi.fn(),
|
||||
sendFindingToJiraMock: vi.fn(),
|
||||
sendJiraDispatchMock: vi.fn(),
|
||||
trackAndPollTaskMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/jira-dispatch", () => ({
|
||||
getJiraIntegrations: getJiraIntegrationsMock,
|
||||
getJiraIssueTypes: getJiraIssueTypesMock,
|
||||
sendFindingToJira: sendFindingToJiraMock,
|
||||
sendJiraDispatch: sendJiraDispatchMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast", () => ({
|
||||
toast: toastMock,
|
||||
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: {
|
||||
PENDING: "pending",
|
||||
READY: "ready",
|
||||
ERROR: "error",
|
||||
},
|
||||
trackAndPollTask: trackAndPollTaskMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
|
||||
EnhancedMultiSelect: ({
|
||||
options,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
options: { value: string; label: string }[];
|
||||
onValueChange: (values: string[]) => void;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onValueChange([options[0]?.value ?? ""])}
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("SendToJiraModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getJiraIntegrationsMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
type: "integrations",
|
||||
id: "jira-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connection_last_checked_at: null,
|
||||
integration_type: "jira",
|
||||
configuration: {
|
||||
domain: "example.atlassian.net",
|
||||
projects: { SEC: "Security" },
|
||||
issue_types: { SEC: ["Task"] },
|
||||
},
|
||||
},
|
||||
links: { self: "/integrations/jira-1" },
|
||||
},
|
||||
],
|
||||
});
|
||||
getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] });
|
||||
sendFindingToJiraMock.mockResolvedValue({
|
||||
success: true,
|
||||
taskId: "task-1",
|
||||
message: "Started",
|
||||
});
|
||||
sendJiraDispatchMock.mockResolvedValue({
|
||||
success: true,
|
||||
taskId: "task-1",
|
||||
message: "Started",
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { created_count: 1, failed_count: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the grouped-vs-separate choice for a target batch with multiple Findings before dispatching", async () => {
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
|
||||
selectedResourceCount={1}
|
||||
description="Create Jira issues for 1 Group and 2 Findings."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Create one Jira issue for all selected Findings"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Create one Jira issue for all selected Findings in this Finding Group",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument();
|
||||
expect(sendFindingToJiraMock).not.toHaveBeenCalled();
|
||||
expect(sendJiraDispatchMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("uses neutral Findings copy for ordinary multi-Finding selections", async () => {
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
findingTitle="Finding 1"
|
||||
selection={targetSelection(
|
||||
["finding-1", "finding-2"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)}
|
||||
defaultDispatchMode="grouped"
|
||||
canChooseGroupedDispatch
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Create one Jira issue for all selected Findings"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Create one Jira issue for all selected Findings in this Finding Group",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("submits mixed Group and Finding batches with the correct dispatch filters and modes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "finding-task",
|
||||
message: "Findings started",
|
||||
});
|
||||
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
|
||||
selectedResourceCount={1}
|
||||
description="Create Jira issues for 1 Group and 2 Findings."
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("radio", { name: "Create separate Jira issues" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2));
|
||||
expect(sendFindingToJiraMock).not.toHaveBeenCalled();
|
||||
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(1, {
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["check-a"],
|
||||
filter: "check_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "grouped",
|
||||
});
|
||||
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, {
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
filter: "finding_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "individual",
|
||||
});
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: "group-task", notifyHandler: false }),
|
||||
);
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: "finding-task", notifyHandler: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a success toast after individual Finding dispatch succeeds", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
selection={targetSelection(
|
||||
["finding-1"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)}
|
||||
findingTitle="Finding 1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success!",
|
||||
description: "Finding successfully sent to Jira!",
|
||||
}),
|
||||
);
|
||||
expect(sendFindingToJiraMock).toHaveBeenCalledWith(
|
||||
"jira-1",
|
||||
"finding-1",
|
||||
"SEC",
|
||||
"Task",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a success toast after grouped Finding Group dispatch succeeds", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success!",
|
||||
description: "Finding successfully sent to Jira!",
|
||||
}),
|
||||
);
|
||||
expect(sendJiraDispatchMock).toHaveBeenCalledWith({
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["check-a"],
|
||||
filter: "check_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "grouped",
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates grouped Finding Group task tracking to the shared watcher", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(trackAndPollTaskMock).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success!",
|
||||
description: "Finding successfully sent to Jira!",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one success toast after mixed Group and Finding batches all succeed", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "finding-task",
|
||||
message: "Findings started",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: "grouped",
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success!",
|
||||
description: "2 Jira issues were created or updated successfully.",
|
||||
}),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a partial success toast when a task reports created and failed issues", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { created_count: 2, failed_count: 1 },
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
selection={targetSelection(
|
||||
["finding-1"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)}
|
||||
findingTitle="Finding 1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Partial success",
|
||||
description:
|
||||
"2 Jira issues were created or updated successfully. Some Jira dispatches failed: Jira dispatch completed with 1 failed and 2 created/updated issues.",
|
||||
}),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Success!" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries only failed Findings after a partial task result", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
trackAndPollTaskMock.mockResolvedValueOnce({
|
||||
status: "ready",
|
||||
result: {
|
||||
created_count: 1,
|
||||
failed_count: 1,
|
||||
failed_finding_ids: ["finding-2"],
|
||||
error: "Jira rejected one Finding.",
|
||||
},
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
selection={targetSelection(
|
||||
["finding-1", "finding-2"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(trackAndPollTaskMock).toHaveBeenCalled());
|
||||
const partialToast = toastMock.mock.calls.find(
|
||||
([toast]) => toast.title === "Partial success",
|
||||
)?.[0];
|
||||
expect(partialToast?.action).toBeDefined();
|
||||
|
||||
await partialToast.action.props.onClick();
|
||||
|
||||
expect(sendFindingToJiraMock).toHaveBeenLastCalledWith(
|
||||
"jira-1",
|
||||
"finding-2",
|
||||
"SEC",
|
||||
"Task",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a partial success toast when one mixed dispatch batch fails after another succeeds", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "finding-task",
|
||||
message: "Findings started",
|
||||
});
|
||||
trackAndPollTaskMock
|
||||
.mockResolvedValueOnce({
|
||||
status: "ready",
|
||||
result: { created_count: 1, failed_count: 0 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: "ready",
|
||||
result: { created_count: 0, failed_count: 1 },
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: "grouped",
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Partial success",
|
||||
description:
|
||||
"Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 0 created/updated issues.",
|
||||
}),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Success!" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("polls started tasks when a later mixed dispatch batch fails to launch", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Failed to launch Finding batch.",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: "grouped",
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: "group-task" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Partial success",
|
||||
description:
|
||||
"Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const partialToast = toastMock.mock.calls.find(
|
||||
([toast]) => toast.title === "Partial success",
|
||||
)?.[0];
|
||||
expect(partialToast?.action).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports polling and launch failures together for mixed dispatches", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Failed to launch Finding batch.",
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValueOnce({
|
||||
status: "error",
|
||||
error: "Jira dispatch completed with 1 failed issue.",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingTitle="Check A"
|
||||
selection={batchSelection([
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: "grouped",
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
])}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description:
|
||||
"Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,49 +2,138 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Send } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
|
||||
import { type Dispatch, type SetStateAction, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
getJiraIntegrations,
|
||||
getJiraIssueTypes,
|
||||
pollJiraDispatchTask,
|
||||
sendFindingToJira,
|
||||
sendJiraDispatch,
|
||||
} from "@/actions/integrations/jira-dispatch";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { CustomBanner } from "@/components/shadcn/custom/custom-banner";
|
||||
import { CustomRadio } from "@/components/shadcn/custom/custom-radio";
|
||||
import { Form, FormField, FormMessage } from "@/components/shadcn/form";
|
||||
import { FormButtons } from "@/components/shadcn/form/form-buttons";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { IntegrationProps } from "@/types/integrations";
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import {
|
||||
evaluateJiraDispatchTask,
|
||||
getJiraDispatchSuccessCount,
|
||||
} from "@/lib/jira-dispatch-result";
|
||||
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
|
||||
import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
|
||||
import {
|
||||
TASK_WATCHER_STATUS,
|
||||
type TaskTrackingResult,
|
||||
trackAndPollTask,
|
||||
} from "@/store/task-watcher/store";
|
||||
import {
|
||||
type IntegrationProps,
|
||||
JIRA_DISPATCH_MODE,
|
||||
JIRA_DISPATCH_TARGET,
|
||||
JIRA_DISPATCH_TASK_KIND,
|
||||
type JiraDispatchMode,
|
||||
type JiraDispatchTargetBatch,
|
||||
type JiraDispatchTaskResult,
|
||||
type JiraSelection,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface SendToJiraModalProps {
|
||||
import {
|
||||
buildJiraDispatchChoiceCopy,
|
||||
JIRA_SELECTION_KIND,
|
||||
} from "./send-to-jira-modal-copy";
|
||||
|
||||
export interface SendToJiraModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
findingId: string;
|
||||
selection: JiraSelection;
|
||||
findingTitle?: string;
|
||||
defaultDispatchMode?: JiraDispatchMode;
|
||||
canChooseGroupedDispatch?: boolean;
|
||||
isFindingGroupSelection?: boolean;
|
||||
selectedResourceCount?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface JiraDispatchSettings {
|
||||
integrationId: string;
|
||||
projectKey: string;
|
||||
issueType: string;
|
||||
dispatchMode: JiraDispatchMode;
|
||||
}
|
||||
|
||||
interface StartedJiraTask {
|
||||
taskId: string;
|
||||
dispatchMode: JiraDispatchMode;
|
||||
}
|
||||
|
||||
interface JiraTrackedOutcome {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
retryBatch?: JiraDispatchTargetBatch;
|
||||
successfulCount?: number;
|
||||
}
|
||||
|
||||
const sendToJiraSchema = z.object({
|
||||
integration: z.string().min(1, "Please select a Jira integration"),
|
||||
project: z.string().min(1, "Please select a project"),
|
||||
issueType: z.string().min(1, "Please select an issue type"),
|
||||
dispatchMode: z.enum([
|
||||
JIRA_DISPATCH_MODE.GROUPED,
|
||||
JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
]),
|
||||
});
|
||||
|
||||
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
|
||||
|
||||
export const SendToJiraModal = ({
|
||||
isOpen,
|
||||
const getConfiguredIssueTypes = (
|
||||
integration: IntegrationProps | undefined,
|
||||
projectKey: string,
|
||||
) => {
|
||||
const configuredIssueTypes = integration?.attributes.configuration
|
||||
.issue_types as Record<string, string[]> | undefined;
|
||||
|
||||
return configuredIssueTypes &&
|
||||
typeof configuredIssueTypes === "object" &&
|
||||
!Array.isArray(configuredIssueTypes)
|
||||
? (configuredIssueTypes[projectKey] ?? [])
|
||||
: [];
|
||||
};
|
||||
|
||||
const getRetryBatch = (
|
||||
failedFindingIds: string[] | undefined,
|
||||
): JiraDispatchTargetBatch | undefined => {
|
||||
const [firstFindingId, ...remainingFindingIds] =
|
||||
failedFindingIds?.filter(Boolean) ?? [];
|
||||
if (!firstFindingId) return undefined;
|
||||
|
||||
return {
|
||||
targetIds: [firstFindingId, ...remainingFindingIds],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
};
|
||||
};
|
||||
|
||||
const SendToJiraModalContent = ({
|
||||
onOpenChange,
|
||||
findingId,
|
||||
selection,
|
||||
findingTitle,
|
||||
}: SendToJiraModalProps) => {
|
||||
const { toast } = useToast();
|
||||
defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
canChooseGroupedDispatch = false,
|
||||
isFindingGroupSelection = false,
|
||||
selectedResourceCount,
|
||||
description,
|
||||
}: Omit<SendToJiraModalProps, "isOpen">) => {
|
||||
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
|
||||
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
|
||||
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true);
|
||||
const [fetchedIssueTypes, setFetchedIssueTypes] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
@@ -56,191 +145,342 @@ export const SendToJiraModal = ({
|
||||
integration: "",
|
||||
project: "",
|
||||
issueType: "",
|
||||
dispatchMode: defaultDispatchMode,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedIntegration = form.watch("integration");
|
||||
const jiraTargetBatches = getJiraSelectionBatches(selection);
|
||||
const findingTargetCount = jiraTargetBatches
|
||||
.filter((batch) => batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID)
|
||||
.reduce((count, batch) => count + batch.targetIds.length, 0);
|
||||
const jiraSelectedResourceCount = selectedResourceCount ?? findingTargetCount;
|
||||
const shouldShowDispatchChoice =
|
||||
(canChooseGroupedDispatch || findingTargetCount > 1) &&
|
||||
(findingTargetCount > 1 || jiraSelectedResourceCount > 1);
|
||||
const checkIdBatches = jiraTargetBatches.filter(
|
||||
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
);
|
||||
const hasOnlySingleFindingGroupBatch =
|
||||
jiraTargetBatches.length === 1 &&
|
||||
checkIdBatches.length === 1 &&
|
||||
checkIdBatches[0].targetIds.length === 1;
|
||||
const isSelectedFindingGroupFlow =
|
||||
shouldShowDispatchChoice &&
|
||||
(isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
|
||||
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
|
||||
selectedCount:
|
||||
findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount,
|
||||
isSelectedFindingGroupFlow,
|
||||
selectionKind:
|
||||
findingTargetCount > 1
|
||||
? JIRA_SELECTION_KIND.FINDINGS
|
||||
: JIRA_SELECTION_KIND.RESOURCES,
|
||||
});
|
||||
|
||||
const selectedIntegration = form.watch("integration");
|
||||
const selectedProject = form.watch("project");
|
||||
const selectedIntegrationData = integrations.find(
|
||||
(integration) => integration.id === selectedIntegration,
|
||||
);
|
||||
const projects =
|
||||
selectedIntegrationData?.attributes.configuration.projects ?? {};
|
||||
const projectEntries = Object.entries(projects);
|
||||
const configuredIssueTypes = getConfiguredIssueTypes(
|
||||
selectedIntegrationData,
|
||||
selectedProject,
|
||||
);
|
||||
const issueTypesForProject =
|
||||
configuredIssueTypes.length > 0
|
||||
? configuredIssueTypes
|
||||
: (fetchedIssueTypes[`${selectedIntegration}:${selectedProject}`] ?? []);
|
||||
const hasConnectedIntegration = integrations.some(
|
||||
(i) => i.attributes.connected === true,
|
||||
(integration) => integration.attributes.connected === true,
|
||||
);
|
||||
|
||||
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
const next = typeof value === "function" ? value(isOpen) : value;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
// Fetch Jira integrations when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const fetchJiraIntegrations = async () => {
|
||||
setIsFetchingIntegrations(true);
|
||||
|
||||
try {
|
||||
const result = await getJiraIntegrations();
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error || "Unable to fetch Jira integrations",
|
||||
);
|
||||
}
|
||||
setIntegrations(result.data);
|
||||
// Auto-select if only one integration
|
||||
if (result.data.length === 1) {
|
||||
form.setValue("integration", result.data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to load Jira integrations";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load integrations",
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingIntegrations(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJiraIntegrations();
|
||||
} else {
|
||||
// Reset form and fetched data when modal closes
|
||||
form.reset();
|
||||
setFetchedIssueTypes({});
|
||||
}
|
||||
}, [isOpen, form, toast]);
|
||||
|
||||
const handleSubmit = async (data: SendToJiraFormData) => {
|
||||
// Close modal immediately; continue processing in background
|
||||
onOpenChange(false);
|
||||
useMountEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// Send the finding to Jira
|
||||
const result = await sendFindingToJira(
|
||||
data.integration,
|
||||
findingId,
|
||||
data.project,
|
||||
data.issueType,
|
||||
);
|
||||
|
||||
const result = await getJiraIntegrations();
|
||||
if (!active) return;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to send to Jira");
|
||||
throw new Error(result.error || "Unable to fetch Jira integrations");
|
||||
}
|
||||
|
||||
// Poll for task completion and notify once
|
||||
const taskResult = await pollJiraDispatchTask(result.taskId);
|
||||
|
||||
if (!taskResult.success) {
|
||||
throw new Error(taskResult.error || "Failed to create Jira issue");
|
||||
setIntegrations(result.data);
|
||||
if (result.data.length === 1) {
|
||||
form.setValue("integration", result.data[0].id);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
description:
|
||||
taskResult.message || "Finding sent to Jira successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to send finding to Jira";
|
||||
: "Failed to load Jira integrations";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
title: "Failed to load integrations",
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
if (active) setIsFetchingIntegrations(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const selectedProject = form.watch("project");
|
||||
|
||||
const selectedIntegrationData = integrations.find(
|
||||
(i) => i.id === selectedIntegration,
|
||||
);
|
||||
|
||||
const projects: Record<string, string> =
|
||||
selectedIntegrationData?.attributes.configuration.projects ??
|
||||
({} as Record<string, string>);
|
||||
|
||||
const projectEntries = Object.entries(projects);
|
||||
|
||||
// Get issue types from config (new dict format), falling back to fetched data
|
||||
const configIssueTypes = selectedIntegrationData?.attributes.configuration
|
||||
.issue_types as Record<string, string[]> | undefined;
|
||||
const issueTypesFromConfig =
|
||||
configIssueTypes &&
|
||||
typeof configIssueTypes === "object" &&
|
||||
!Array.isArray(configIssueTypes)
|
||||
? (configIssueTypes[selectedProject] ?? [])
|
||||
: [];
|
||||
const issueTypesForProject =
|
||||
issueTypesFromConfig.length > 0
|
||||
? issueTypesFromConfig
|
||||
: (fetchedIssueTypes[selectedProject] ?? []);
|
||||
|
||||
// Fetch issue types from API when project is selected but no types are available
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
if (
|
||||
selectedIntegration &&
|
||||
selectedProject &&
|
||||
issueTypesFromConfig.length === 0 &&
|
||||
!fetchedIssueTypes[selectedProject]
|
||||
) {
|
||||
const fetchIssueTypes = async () => {
|
||||
setIsFetchingIssueTypes(true);
|
||||
try {
|
||||
const result = await getJiraIssueTypes(
|
||||
selectedIntegration,
|
||||
selectedProject,
|
||||
);
|
||||
if (ignore) return;
|
||||
if (result.success) {
|
||||
setFetchedIssueTypes((prev) => ({
|
||||
...prev,
|
||||
[selectedProject]: result.issueTypes,
|
||||
}));
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load issue types",
|
||||
description:
|
||||
result.error || "Unable to fetch issue types for this project",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) setIsFetchingIssueTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchIssueTypes();
|
||||
}
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
selectedIntegration,
|
||||
selectedProject,
|
||||
issueTypesFromConfig.length,
|
||||
fetchedIssueTypes,
|
||||
toast,
|
||||
]);
|
||||
});
|
||||
|
||||
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
const nextOpen = typeof value === "function" ? value(true) : value;
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const loadIssueTypes = async (integrationId: string, projectKey: string) => {
|
||||
const integration = integrations.find((item) => item.id === integrationId);
|
||||
if (
|
||||
!integrationId ||
|
||||
!projectKey ||
|
||||
getConfiguredIssueTypes(integration, projectKey).length > 0 ||
|
||||
fetchedIssueTypes[`${integrationId}:${projectKey}`]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingIssueTypes(true);
|
||||
try {
|
||||
const result = await getJiraIssueTypes(integrationId, projectKey);
|
||||
if (result.success) {
|
||||
setFetchedIssueTypes((current) => ({
|
||||
...current,
|
||||
[`${integrationId}:${projectKey}`]: result.issueTypes,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load issue types",
|
||||
description:
|
||||
result.error || "Unable to fetch issue types for this project",
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load issue types",
|
||||
description: "Unable to fetch issue types for this project",
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingIssueTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function processBatches(
|
||||
batches: JiraDispatchTargetBatch[],
|
||||
settings: JiraDispatchSettings,
|
||||
) {
|
||||
const startedTasks: StartedJiraTask[] = [];
|
||||
const launchErrors: string[] = [];
|
||||
const unknownLaunchErrors: string[] = [];
|
||||
|
||||
for (const batch of batches) {
|
||||
const dispatchMode = batch.dispatchMode ?? settings.dispatchMode;
|
||||
try {
|
||||
const result =
|
||||
batch.targetIds.length === 1 &&
|
||||
batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID &&
|
||||
dispatchMode === JIRA_DISPATCH_MODE.INDIVIDUAL
|
||||
? await sendFindingToJira(
|
||||
settings.integrationId,
|
||||
batch.targetIds[0],
|
||||
settings.projectKey,
|
||||
settings.issueType,
|
||||
)
|
||||
: await sendJiraDispatch({
|
||||
integrationId: settings.integrationId,
|
||||
targetIds: batch.targetIds,
|
||||
filter: batch.targetType,
|
||||
projectKey: settings.projectKey,
|
||||
issueType: settings.issueType,
|
||||
dispatchMode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
launchErrors.push(result.error || "Failed to send to Jira");
|
||||
continue;
|
||||
}
|
||||
|
||||
startedTasks.push({ taskId: result.taskId, dispatchMode });
|
||||
} catch {
|
||||
// The request may have reached the server before the RPC failed. Do
|
||||
// not offer an automatic retry because that could create duplicates.
|
||||
unknownLaunchErrors.push(
|
||||
"The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedOutcomes = await Promise.all(
|
||||
startedTasks.map(async ({ taskId, dispatchMode }) => {
|
||||
let trackedTask: TaskTrackingResult<JiraDispatchTaskResult>;
|
||||
try {
|
||||
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
|
||||
taskId,
|
||||
kind: JIRA_DISPATCH_TASK_KIND,
|
||||
meta: buildJiraDispatchTaskMeta({
|
||||
integrationId: settings.integrationId,
|
||||
projectKey: settings.projectKey,
|
||||
issueType: settings.issueType,
|
||||
dispatchMode,
|
||||
}),
|
||||
notifyHandler: false,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.",
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
if (trackedTask.status !== TASK_WATCHER_STATUS.READY) {
|
||||
return {
|
||||
success: false,
|
||||
error: trackedTask.error || "Failed to track Jira issue creation.",
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
const outcome = evaluateJiraDispatchTask(
|
||||
"completed",
|
||||
trackedTask.result,
|
||||
);
|
||||
if (outcome.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: outcome.message,
|
||||
warning: outcome.warning,
|
||||
retryBatch: getRetryBatch(outcome.failedFindingIds),
|
||||
successfulCount: getJiraDispatchSuccessCount(trackedTask.result),
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: outcome.error,
|
||||
retryBatch: getRetryBatch(outcome.failedFindingIds),
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}),
|
||||
);
|
||||
|
||||
const successfulOutcomes = trackedOutcomes.filter(
|
||||
(outcome) => outcome.success,
|
||||
);
|
||||
const warnings = Array.from(
|
||||
new Set(
|
||||
trackedOutcomes.flatMap((outcome) =>
|
||||
outcome.warning ? [outcome.warning] : [],
|
||||
),
|
||||
),
|
||||
);
|
||||
const successfulIssueCount = successfulOutcomes.reduce(
|
||||
(count, outcome) => count + (outcome.successfulCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const successMessage =
|
||||
successfulOutcomes.length === 1
|
||||
? successfulOutcomes[0].message
|
||||
: `${successfulIssueCount} Jira issues were created or updated successfully.`;
|
||||
const errors = Array.from(
|
||||
new Set([
|
||||
...trackedOutcomes.flatMap((outcome) =>
|
||||
outcome.error ? [outcome.error] : [],
|
||||
),
|
||||
...launchErrors,
|
||||
...unknownLaunchErrors,
|
||||
]),
|
||||
);
|
||||
const retryBatch = getRetryBatch(
|
||||
Array.from(
|
||||
new Set(
|
||||
trackedOutcomes.flatMap(
|
||||
(outcome) => outcome.retryBatch?.targetIds ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const retryBatches = retryBatch ? [retryBatch] : [];
|
||||
const retryAction =
|
||||
retryBatches.length > 0 ? (
|
||||
<ToastAction
|
||||
altText="Retry failed Jira dispatches"
|
||||
onClick={async () => {
|
||||
toast({
|
||||
title: "Retry started",
|
||||
description: "Retrying only the Jira dispatches that failed.",
|
||||
});
|
||||
await processBatches(retryBatches, settings);
|
||||
}}
|
||||
>
|
||||
Retry failed
|
||||
</ToastAction>
|
||||
) : undefined;
|
||||
|
||||
if (errors.length > 0 || warnings.length > 0) {
|
||||
if (successfulOutcomes.length > 0) {
|
||||
toast({
|
||||
title: "Partial success",
|
||||
description: `${successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[
|
||||
...warnings,
|
||||
...errors,
|
||||
].join(" ")}`,
|
||||
...(retryAction ? { action: retryAction } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: [...warnings, ...errors].join(" "),
|
||||
...(retryAction ? { action: retryAction } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: successMessage || "Finding sent to Jira successfully",
|
||||
});
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: SendToJiraFormData) => {
|
||||
onOpenChange(false);
|
||||
|
||||
void processBatches(jiraTargetBatches, {
|
||||
integrationId: data.integration,
|
||||
projectKey: data.project,
|
||||
issueType: data.issueType,
|
||||
dispatchMode: data.dispatchMode,
|
||||
}).catch(() => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description:
|
||||
"The Jira dispatch could not be processed. Check Jira before retrying.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const issueTypeOptions = issueTypesForProject.map((type) => ({
|
||||
value: type,
|
||||
label: type,
|
||||
}));
|
||||
|
||||
const integrationOptions = integrations.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.attributes.configuration.domain || integration.id,
|
||||
}));
|
||||
|
||||
const projectOptions = projectEntries.map(([key, name]) => ({
|
||||
value: key,
|
||||
label: `${key} - ${name}`,
|
||||
@@ -248,13 +488,17 @@ export const SendToJiraModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
title="Send Finding to Jira"
|
||||
description={
|
||||
findingTitle
|
||||
? `Create a Jira issue for: "${findingTitle}"`
|
||||
: "Select integration, project and issue type to create a Jira issue"
|
||||
description
|
||||
? description
|
||||
: shouldShowDispatchChoice
|
||||
? jiraDispatchChoiceCopy.description
|
||||
: findingTitle
|
||||
? `Create a Jira issue for: "${findingTitle}"`
|
||||
: "Select integration, project and issue type to create a Jira issue"
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
@@ -262,7 +506,6 @@ export const SendToJiraModal = ({
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Loading skeleton for project selector */}
|
||||
{isFetchingIntegrations && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
@@ -270,7 +513,6 @@ export const SendToJiraModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Selection */}
|
||||
{!isFetchingIntegrations && integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -287,22 +529,20 @@ export const SendToJiraModal = ({
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
field.onChange(values.at(-1) ?? "");
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "");
|
||||
setFetchedIssueTypes({});
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
searchable
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
hideSelectAll
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
closeOnSelect
|
||||
resetOnDefaultValueChange
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
@@ -310,7 +550,6 @@ export const SendToJiraModal = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Selection */}
|
||||
{!isFetchingIntegrations &&
|
||||
selectedIntegration &&
|
||||
projectEntries.length > 0 && (
|
||||
@@ -329,19 +568,19 @@ export const SendToJiraModal = ({
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
const projectKey = values.at(-1) ?? "";
|
||||
field.onChange(projectKey);
|
||||
form.setValue("issueType", "");
|
||||
void loadIssueTypes(selectedIntegration, projectKey);
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
searchable
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
hideSelectAll
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
closeOnSelect
|
||||
resetOnDefaultValueChange
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
@@ -349,7 +588,6 @@ export const SendToJiraModal = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -365,23 +603,22 @@ export const SendToJiraModal = ({
|
||||
<EnhancedMultiSelect
|
||||
id="jira-issue-type-select"
|
||||
options={issueTypeOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
onValueChange={(values) =>
|
||||
field.onChange(values.at(-1) ?? "")
|
||||
}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder={
|
||||
isFetchingIssueTypes
|
||||
? "Loading issue types..."
|
||||
: "Select an issue type"
|
||||
}
|
||||
searchable={true}
|
||||
searchable
|
||||
emptyIndicator="No issue types found."
|
||||
disabled={isFetchingIssueTypes}
|
||||
hideSelectAll={true}
|
||||
hideSelectAll
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
closeOnSelect
|
||||
resetOnDefaultValueChange
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
@@ -389,7 +626,52 @@ export const SendToJiraModal = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{shouldShowDispatchChoice && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dispatchMode"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-text-neutral-secondary text-xs font-light tracking-tight">
|
||||
Jira issue creation mode
|
||||
</span>
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<CustomRadio
|
||||
value={JIRA_DISPATCH_MODE.GROUPED}
|
||||
ariaLabel="Create one Jira issue"
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{jiraDispatchChoiceCopy.groupedTitle}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{jiraDispatchChoiceCopy.groupedHelp}
|
||||
</span>
|
||||
</span>
|
||||
</CustomRadio>
|
||||
<CustomRadio
|
||||
value={JIRA_DISPATCH_MODE.INDIVIDUAL}
|
||||
ariaLabel="Create separate Jira issues"
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Create separate Jira issues
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{jiraDispatchChoiceCopy.individualHelp}
|
||||
</span>
|
||||
</span>
|
||||
</CustomRadio>
|
||||
</RadioGroup>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isFetchingIntegrations &&
|
||||
(integrations.length === 0 || !hasConnectedIntegration) ? (
|
||||
<CustomBanner
|
||||
@@ -421,3 +703,9 @@ export const SendToJiraModal = ({
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return <SendToJiraModalContent {...props} />;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,25 @@ import type {
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface JiraModalMockProps {
|
||||
selection: { targetId?: string };
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
|
||||
() => ({
|
||||
SendToJiraModalMock: vi.fn(({ selection, isOpen }: JiraModalMockProps) => (
|
||||
<div
|
||||
data-testid="jira-modal"
|
||||
data-finding-id={selection.targetId}
|
||||
data-open={isOpen ? "true" : "false"}
|
||||
/>
|
||||
)),
|
||||
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
|
||||
}),
|
||||
);
|
||||
|
||||
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
|
||||
vi.mock("@/components/shadcn/custom/custom-link", () => ({
|
||||
@@ -14,8 +32,7 @@ vi.mock("@/components/shadcn/custom/custom-link", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
@@ -42,19 +59,7 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: ({
|
||||
findingId,
|
||||
isOpen,
|
||||
}: {
|
||||
findingId: string;
|
||||
isOpen: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="jira-modal"
|
||||
data-finding-id={findingId}
|
||||
data-open={isOpen ? "true" : "false"}
|
||||
/>
|
||||
),
|
||||
SendToJiraModal: SendToJiraModalMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
@@ -69,12 +74,14 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
<button disabled={disabled} onClick={onSelect} title={disabledTooltip}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
@@ -175,6 +182,11 @@ vi.mock("@/lib/date-utils", () => ({
|
||||
getFailingForLabel: () => "2d",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deployment", () => ({
|
||||
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
|
||||
}));
|
||||
|
||||
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
@@ -196,6 +208,7 @@ import {
|
||||
CLOUD_ONLY_TOOLTIP_COPY,
|
||||
EDITING_UNAVAILABLE_COPY,
|
||||
} from "./finding-triage-cells";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
@@ -284,6 +297,11 @@ function renderResourceActionsCell({
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should render actions as the last visible column after Triage without Notes", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
@@ -296,6 +314,7 @@ describe("column-finding-resources", () => {
|
||||
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("status");
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(
|
||||
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
|
||||
@@ -518,4 +537,107 @@ describe("column-finding-resources", () => {
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass selected same-group affected failing resources as grouped Jira targets", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 2,
|
||||
});
|
||||
const actionColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!actionColumn?.cell) {
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
const CellComponent = actionColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: ["finding-1", "finding-2"],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
}}
|
||||
>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource({ findingId: "finding-1" }),
|
||||
},
|
||||
})}
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
defaultDispatchMode: "grouped",
|
||||
canChooseGroupedDispatch: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable selected multi-finding Jira dispatch outside cloud", async () => {
|
||||
// Given
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 2,
|
||||
});
|
||||
const actionColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!actionColumn?.cell) {
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
const CellComponent = actionColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: ["finding-1", "finding-2"],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
}}
|
||||
>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource({ findingId: "finding-1" }),
|
||||
},
|
||||
})}
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
const jiraButton = screen.getByRole("button", { name: "Send to Jira" });
|
||||
await user.click(jiraButton);
|
||||
|
||||
// Then
|
||||
expect(jiraButton).toBeDisabled();
|
||||
expect(jiraButton).toHaveAttribute(
|
||||
"title",
|
||||
"Available only in Prowler Cloud",
|
||||
);
|
||||
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,18 @@ import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { SeverityBadge } from "@/components/shadcn/table";
|
||||
import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import {
|
||||
isGroupedJiraDispatchEnabled,
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP,
|
||||
} from "@/lib/deployment";
|
||||
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import {
|
||||
@@ -69,6 +71,7 @@ const ResourceRowActions = ({
|
||||
|
||||
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
|
||||
const hasMultipleSelected = selectedFindingIds.length > 1;
|
||||
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
|
||||
|
||||
const getDisplayIds = (): string[] => {
|
||||
if (isCurrentSelected && hasMultipleSelected) {
|
||||
@@ -83,6 +86,12 @@ const ResourceRowActions = ({
|
||||
if (ids.length > 1) return `Mute ${ids.length}`;
|
||||
return "Mute";
|
||||
};
|
||||
const displayIds = getDisplayIds();
|
||||
const canSendToJira = displayIds.length === 1 || groupedJiraDispatchEnabled;
|
||||
const jiraSelection = createJiraTargetSelection(
|
||||
displayIds,
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
);
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
const displayIds = getDisplayIds();
|
||||
@@ -123,12 +132,22 @@ const ResourceRowActions = ({
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={resource.findingId}
|
||||
findingTitle={resource.checkId}
|
||||
/>
|
||||
{jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
selection={jiraSelection}
|
||||
findingTitle={resource.checkId}
|
||||
defaultDispatchMode={
|
||||
displayIds.length > 1
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: JIRA_DISPATCH_MODE.INDIVIDUAL
|
||||
}
|
||||
canChooseGroupedDispatch={
|
||||
displayIds.length > 1 && groupedJiraDispatchEnabled
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -162,7 +181,11 @@ const ResourceRowActions = ({
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
disabled={!canSendToJira}
|
||||
disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
|
||||
onSelect={() => {
|
||||
if (canSendToJira) setIsJiraModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
@@ -243,24 +266,14 @@ export function getColumnFindingResources({
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Status
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<StatusFindingBadge status={row.original.status as FindingStatus} />
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Resource — name + uid
|
||||
// Affected failing resource — name + uid
|
||||
{
|
||||
id: "resource",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Resource" />
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Affected failing resource"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[240px]">
|
||||
|
||||
@@ -2,8 +2,29 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { MuteFindingsModalMock } = vi.hoisted(() => ({
|
||||
MuteFindingsModalMock: vi.fn(() => null),
|
||||
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
DataTableRowActions,
|
||||
type FindingRowData,
|
||||
} from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
const { MuteFindingsModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
|
||||
() => ({
|
||||
MuteFindingsModalMock: vi.fn(() => null),
|
||||
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
|
||||
}),
|
||||
);
|
||||
|
||||
const { SendToJiraModalMock } = vi.hoisted(() => ({
|
||||
SendToJiraModalMock: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -15,7 +36,12 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
SendToJiraModal: SendToJiraModalMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deployment", () => ({
|
||||
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
@@ -30,12 +56,20 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
tooltip?: string;
|
||||
}) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
title={tooltip ?? disabledTooltip}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
@@ -74,18 +108,6 @@ vi.mock("./finding-note-modal", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
DataTableRowActions,
|
||||
type FindingRowData,
|
||||
} from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
function deferredPromise<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -134,6 +156,8 @@ function makeFindingRow(overrides?: Partial<FindingRowData>) {
|
||||
describe("DataTableRowActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
it("opens the mute modal immediately in preparing state for finding groups", async () => {
|
||||
@@ -252,6 +276,292 @@ describe("DataTableRowActions", () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("allows choosing Jira dispatch mode for a group with multiple failing resources", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: [],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "S3 bucket public access",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 2,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Send Finding Group to Jira" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
selection: {
|
||||
kind: "single",
|
||||
targetId: "s3_bucket_public_access",
|
||||
targetType: "check_id",
|
||||
},
|
||||
defaultDispatchMode: "grouped",
|
||||
canChooseGroupedDispatch: true,
|
||||
selectedResourceCount: 2,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the Cloud tooltip and opens the upgrade modal for groups outside cloud", async () => {
|
||||
// Given
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: [],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "S3 bucket public access",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 2,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
const jiraButton = screen.getByRole("button", {
|
||||
name: "Send Finding Group to Jira",
|
||||
});
|
||||
await user.click(jiraButton);
|
||||
|
||||
// Then
|
||||
expect(jiraButton).toBeVisible();
|
||||
expect(jiraButton).toBeEnabled();
|
||||
expect(jiraButton).toHaveAttribute(
|
||||
"title",
|
||||
"Available only in Prowler Cloud",
|
||||
);
|
||||
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
|
||||
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
|
||||
);
|
||||
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer Jira dispatch mode choice for a group with one failing resource", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: [],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "S3 bucket public access",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 1,
|
||||
resourcesTotal: 1,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Send Finding Group to Jira" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
selection: {
|
||||
kind: "single",
|
||||
targetId: "s3_bucket_public_access",
|
||||
targetType: "check_id",
|
||||
},
|
||||
defaultDispatchMode: "grouped",
|
||||
canChooseGroupedDispatch: false,
|
||||
selectedResourceCount: 1,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses grouped Jira dispatch for mixed selected finding groups", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: ["check-a", "check-b"],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "check-a",
|
||||
checkTitle: "Check A",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 2,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Send 2 Finding Groups to Jira" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetIds: ["check-a", "check-b"],
|
||||
targetType: "check_id",
|
||||
},
|
||||
defaultDispatchMode: "grouped",
|
||||
canChooseGroupedDispatch: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows choosing Jira dispatch mode for multiple selected findings", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: ["finding-1", "finding-2"],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions row={makeFindingRow()} />
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Send 2 Findings to Jira" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
defaultDispatchMode: "grouped",
|
||||
canChooseGroupedDispatch: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps single finding Jira dispatch enabled when other rows are selected outside cloud", async () => {
|
||||
// Given
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: ["finding-2", "finding-3"],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions row={makeFindingRow()} />
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
|
||||
).toBeEnabled();
|
||||
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
isOpen: true,
|
||||
selection: {
|
||||
kind: "single",
|
||||
targetId: "finding-1",
|
||||
targetType: "finding_id",
|
||||
},
|
||||
defaultDispatchMode: "individual",
|
||||
canChooseGroupedDispatch: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows Add Triage Note for editable findings without a note", () => {
|
||||
// Given / When
|
||||
render(
|
||||
|
||||
@@ -13,12 +13,20 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import {
|
||||
isGroupedJiraDispatchEnabled,
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP,
|
||||
} from "@/lib/deployment";
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
@@ -115,6 +123,9 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
|
||||
const resolvedFindingContext = findingContext ?? {
|
||||
@@ -149,6 +160,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
// Otherwise, just mute this single finding
|
||||
const isCurrentSelected = selectedFindingIds.includes(muteKey);
|
||||
const hasMultipleSelected = selectedFindingIds.length > 1;
|
||||
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
|
||||
|
||||
const getDisplayIds = (): string[] => {
|
||||
if (isCurrentSelected && hasMultipleSelected) {
|
||||
@@ -166,6 +178,45 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
return isGroup ? "Mute Finding Group" : "Mute Finding";
|
||||
};
|
||||
|
||||
const getJiraTargetIds = (): string[] => {
|
||||
if (isCurrentSelected && hasMultipleSelected) {
|
||||
return selectedFindingIds;
|
||||
}
|
||||
return [muteKey];
|
||||
};
|
||||
|
||||
const getJiraLabel = () => {
|
||||
const ids = getJiraTargetIds();
|
||||
if (ids.length > 1) {
|
||||
return `Send ${ids.length} ${isGroup ? "Finding Groups" : "Findings"} to Jira`;
|
||||
}
|
||||
return isGroup ? "Send Finding Group to Jira" : "Send 1 Finding to Jira";
|
||||
};
|
||||
|
||||
const jiraTargetIds = getJiraTargetIds();
|
||||
const jiraTargetType = isGroup
|
||||
? JIRA_DISPATCH_TARGET.CHECK_ID
|
||||
: JIRA_DISPATCH_TARGET.FINDING_ID;
|
||||
const jiraSelection = createJiraTargetSelection(
|
||||
jiraTargetIds,
|
||||
jiraTargetType,
|
||||
);
|
||||
const requiresJiraUpgrade =
|
||||
(isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled;
|
||||
const selectedJiraResourceCount = isGroup
|
||||
? (finding.resourcesFail ?? 0)
|
||||
: undefined;
|
||||
const hasMultipleSelectedFindings = !isGroup && jiraTargetIds.length > 1;
|
||||
const jiraDefaultDispatchMode =
|
||||
isGroup || hasMultipleSelectedFindings
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: JIRA_DISPATCH_MODE.INDIVIDUAL;
|
||||
const canChooseGroupedJiraDispatch = groupedJiraDispatchEnabled
|
||||
? isGroup
|
||||
? jiraTargetIds.length === 1 && (selectedJiraResourceCount ?? 0) > 1
|
||||
: hasMultipleSelectedFindings
|
||||
: false;
|
||||
|
||||
const handleMuteModalOpenChange = (
|
||||
nextOpen: boolean | ((previousOpen: boolean) => boolean),
|
||||
) => {
|
||||
@@ -226,14 +277,26 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleJiraClick = () => {
|
||||
if (requiresJiraUpgrade) {
|
||||
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJiraModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isGroup && (
|
||||
{(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={finding.id}
|
||||
findingTitle={findingTitle}
|
||||
selection={jiraSelection}
|
||||
defaultDispatchMode={jiraDefaultDispatchMode}
|
||||
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
|
||||
selectedResourceCount={selectedJiraResourceCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -274,13 +337,14 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
disabled={!canMute || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
{!isGroup && (
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label={getJiraLabel()}
|
||||
tooltip={
|
||||
requiresJiraUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
|
||||
}
|
||||
onSelect={handleJiraClick}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,4 +13,14 @@ describe("findings group drill down", () => {
|
||||
expect(source).toContain("useFindingGroupResourceState");
|
||||
expect(source).not.toContain("useInfiniteResources");
|
||||
});
|
||||
|
||||
it("routes selected child findings through the Send to Jira modal with issue creation mode", () => {
|
||||
expect(source).toContain("<SendToJiraModal");
|
||||
expect(source).toContain("JIRA_DISPATCH_TARGET.FINDING_ID");
|
||||
expect(source).toContain(
|
||||
"selectedFindingIds.length > 1 && groupedJiraDispatchEnabled",
|
||||
);
|
||||
expect(source).toContain("canSendSelectedFindingsToJira");
|
||||
expect(source).toContain("JIRA_DISPATCH_MODE.GROUPED");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -25,12 +27,15 @@ import {
|
||||
} from "@/components/shadcn/table";
|
||||
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
|
||||
import { cn, hasHistoricalFindingFilter } from "@/lib";
|
||||
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
|
||||
import {
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupImpactedCounts,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
|
||||
@@ -51,6 +56,8 @@ export function FindingsGroupDrillDown({
|
||||
onCollapse,
|
||||
}: FindingsGroupDrillDownProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
|
||||
|
||||
// Keep drill-down endpoint selection aligned with the grouped findings page.
|
||||
const currentParams = Object.fromEntries(searchParams.entries());
|
||||
@@ -113,6 +120,12 @@ export function FindingsGroupDrillDown({
|
||||
const impactedCounts = getFindingGroupImpactedCounts(group);
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
const canSendSelectedFindingsToJira =
|
||||
selectedFindingIds.length === 1 || groupedJiraDispatchEnabled;
|
||||
const jiraSelection = createJiraTargetSelection(
|
||||
selectedFindingIds,
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
);
|
||||
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
@@ -127,7 +140,7 @@ export function FindingsGroupDrillDown({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-secondary rounded-[14px] shadow-sm",
|
||||
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
|
||||
"flex w-full flex-col overflow-auto border",
|
||||
)}
|
||||
>
|
||||
@@ -235,11 +248,37 @@ export function FindingsGroupDrillDown({
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
muteLabel={`Mute ${selectedFindingIds.length} ${
|
||||
selectedFindingIds.length === 1 ? "Finding" : "Findings"
|
||||
}`}
|
||||
onBeforeOpen={async () => {
|
||||
return resolveSelectedFindingIds(selectedFindingIds);
|
||||
}}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation
|
||||
isBulkOperation={selectedFindingIds.length > 1}
|
||||
showSendToJira
|
||||
canSendToJira={canSendSelectedFindingsToJira}
|
||||
sendToJiraLabel={`Send ${selectedFindingIds.length} ${
|
||||
selectedFindingIds.length === 1 ? "Finding" : "Findings"
|
||||
} to Jira`}
|
||||
onSendToJira={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
selection={jiraSelection}
|
||||
findingTitle={group.checkTitle}
|
||||
defaultDispatchMode={
|
||||
selectedFindingIds.length > 1
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: JIRA_DISPATCH_MODE.INDIVIDUAL
|
||||
}
|
||||
canChooseGroupedDispatch={
|
||||
selectedFindingIds.length > 1 && groupedJiraDispatchEnabled
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,19 @@ import { Suspense, useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
|
||||
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
|
||||
import { DataTable } from "@/components/shadcn/table";
|
||||
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
|
||||
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
|
||||
import {
|
||||
createJiraBatchSelection,
|
||||
createJiraTargetSelection,
|
||||
} from "@/lib/jira-dispatch-selection";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
|
||||
@@ -24,18 +31,49 @@ import {
|
||||
} from "./inline-resource-container";
|
||||
|
||||
const exploreFindingsFlow = getFlowById("explore-findings")!;
|
||||
const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
|
||||
|
||||
function buildMuteLabel(groupCount: number, resourceCount: number): string {
|
||||
const parts: string[] = [];
|
||||
if (groupCount > 0) {
|
||||
parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
|
||||
}
|
||||
if (resourceCount > 0) {
|
||||
parts.push(
|
||||
`${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
|
||||
);
|
||||
}
|
||||
return `Mute ${parts.join(" and ")}`;
|
||||
function buildSelectionSummary(
|
||||
groupCount: number,
|
||||
findingCount: number,
|
||||
): string {
|
||||
return `${buildSelectionEntityLabel(groupCount, findingCount)} selected`;
|
||||
}
|
||||
|
||||
function buildMuteActionLabel(
|
||||
groupCount: number,
|
||||
findingCount: number,
|
||||
): string {
|
||||
return `Mute ${buildSelectionEntityLabel(groupCount, findingCount)}`;
|
||||
}
|
||||
|
||||
function buildJiraActionLabel(
|
||||
groupCount: number,
|
||||
findingCount: number,
|
||||
): string {
|
||||
return `Send ${buildSelectionEntityLabel(groupCount, findingCount)} to Jira`;
|
||||
}
|
||||
|
||||
function buildSelectionEntityLabel(
|
||||
groupCount: number,
|
||||
findingCount: number,
|
||||
): string {
|
||||
const parts = [
|
||||
buildEntityCountLabel(groupCount, "Group", "Groups"),
|
||||
buildEntityCountLabel(findingCount, "Finding", "Findings"),
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(" and ");
|
||||
}
|
||||
|
||||
function buildEntityCountLabel(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string,
|
||||
): string | null {
|
||||
if (count === 0) return null;
|
||||
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
interface FindingsGroupTableProps {
|
||||
@@ -43,6 +81,7 @@ interface FindingsGroupTableProps {
|
||||
metadata?: MetaDataProps;
|
||||
resolvedFilters: Record<string, string>;
|
||||
hasHistoricalData: boolean;
|
||||
expandedCheckId?: string;
|
||||
}
|
||||
|
||||
export function FindingsGroupTable({
|
||||
@@ -50,23 +89,70 @@ export function FindingsGroupTable({
|
||||
metadata,
|
||||
resolvedFilters,
|
||||
hasHistoricalData,
|
||||
expandedCheckId: requestedExpandedCheckId,
|
||||
}: FindingsGroupTableProps) {
|
||||
const safeData = data ?? EMPTY_FINDING_GROUPS;
|
||||
const requestedGroup = requestedExpandedCheckId
|
||||
? safeData.find((group) => group.checkId === requestedExpandedCheckId)
|
||||
: undefined;
|
||||
const initialExpandedCheckId =
|
||||
requestedGroup && canDrillDownFindingGroup(requestedGroup)
|
||||
? requestedGroup.checkId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FindingsGroupTableContent
|
||||
key={`${requestedExpandedCheckId ?? "manual"}:${initialExpandedCheckId ?? "collapsed"}`}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
resolvedFilters={resolvedFilters}
|
||||
hasHistoricalData={hasHistoricalData}
|
||||
initialExpandedCheckId={initialExpandedCheckId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FindingsGroupTableContentProps {
|
||||
data: FindingGroupRow[];
|
||||
metadata?: MetaDataProps;
|
||||
resolvedFilters: Record<string, string>;
|
||||
hasHistoricalData: boolean;
|
||||
initialExpandedCheckId: string | null;
|
||||
}
|
||||
|
||||
const FindingsGroupTableContent = ({
|
||||
data,
|
||||
metadata,
|
||||
resolvedFilters,
|
||||
hasHistoricalData,
|
||||
initialExpandedCheckId,
|
||||
}: FindingsGroupTableContentProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
|
||||
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState<
|
||||
string | null
|
||||
>(initialExpandedCheckId);
|
||||
// Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer.
|
||||
const [resourceSearchInput, setResourceSearchInput] = useState("");
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const inlineRef = useRef<InlineResourceContainerHandle>(null);
|
||||
|
||||
const safeData = data ?? [];
|
||||
const hasResourceSelection = resourceSelection.length > 0;
|
||||
const safeData = data ?? EMPTY_FINDING_GROUPS;
|
||||
const expandedGroupCandidate = selectedExpandedCheckId
|
||||
? safeData.find((group) => group.checkId === selectedExpandedCheckId)
|
||||
: undefined;
|
||||
const expandedGroup =
|
||||
expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate)
|
||||
? expandedGroupCandidate
|
||||
: null;
|
||||
const expandedCheckId = expandedGroup?.checkId ?? null;
|
||||
const activeResourceSelection = expandedCheckId ? resourceSelection : [];
|
||||
const hasResourceSelection = activeResourceSelection.length > 0;
|
||||
const filters = resolvedFilters;
|
||||
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
|
||||
|
||||
// Exclude expanded group from group-level mutes when it has resource selections.
|
||||
const selectedCheckIds = Object.keys(rowSelection)
|
||||
@@ -82,6 +168,76 @@ export function FindingsGroupTable({
|
||||
.map((idx) => safeData[parseInt(idx)])
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedGroupTitle =
|
||||
selectedFindings.length === 1 ? selectedFindings[0]?.checkTitle : undefined;
|
||||
const hasMixedJiraSelection =
|
||||
selectedCheckIds.length > 0 && hasResourceSelection;
|
||||
const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0;
|
||||
const jiraTargetIds = jiraGroupSelectionTakesPrecedence
|
||||
? selectedCheckIds
|
||||
: activeResourceSelection;
|
||||
const jiraTargetType = jiraGroupSelectionTakesPrecedence
|
||||
? JIRA_DISPATCH_TARGET.CHECK_ID
|
||||
: JIRA_DISPATCH_TARGET.FINDING_ID;
|
||||
const singleSelectedGroup =
|
||||
selectedCheckIds.length === 1
|
||||
? selectedFindings.find(
|
||||
(finding) => finding.checkId === selectedCheckIds[0],
|
||||
)
|
||||
: undefined;
|
||||
const selectedJiraResourceCount = jiraGroupSelectionTakesPrecedence
|
||||
? singleSelectedGroup
|
||||
? singleSelectedGroup.resourcesFail
|
||||
: selectedCheckIds.length
|
||||
: activeResourceSelection.length;
|
||||
const jiraDispatchMode = jiraGroupSelectionTakesPrecedence
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: activeResourceSelection.length > 1
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: JIRA_DISPATCH_MODE.INDIVIDUAL;
|
||||
const canChooseGroupedJiraDispatch = jiraGroupSelectionTakesPrecedence
|
||||
? !hasMixedJiraSelection &&
|
||||
selectedCheckIds.length === 1 &&
|
||||
selectedJiraResourceCount > 1
|
||||
: activeResourceSelection.length > 1;
|
||||
const jiraTitle = hasMixedJiraSelection
|
||||
? undefined
|
||||
: jiraGroupSelectionTakesPrecedence
|
||||
? selectedGroupTitle
|
||||
: expandedGroup?.checkTitle;
|
||||
const jiraSelection = hasMixedJiraSelection
|
||||
? createJiraBatchSelection([
|
||||
{
|
||||
targetIds: selectedCheckIds,
|
||||
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
{
|
||||
targetIds: activeResourceSelection,
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
...(activeResourceSelection.length > 1
|
||||
? {}
|
||||
: { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }),
|
||||
},
|
||||
])
|
||||
: createJiraTargetSelection(jiraTargetIds, jiraTargetType);
|
||||
const jiraDescription = hasMixedJiraSelection
|
||||
? `Create Jira issues for ${buildSelectionEntityLabel(
|
||||
selectedCheckIds.length,
|
||||
activeResourceSelection.length,
|
||||
)}.`
|
||||
: undefined;
|
||||
const hasJiraTargets = jiraTargetIds.length > 0;
|
||||
const isSingleFindingJiraDispatch =
|
||||
!jiraGroupSelectionTakesPrecedence && activeResourceSelection.length === 1;
|
||||
const canSendToJira =
|
||||
hasJiraTargets &&
|
||||
(isSingleFindingJiraDispatch || groupedJiraDispatchEnabled);
|
||||
const sendToJiraLabel = buildJiraActionLabel(
|
||||
selectedCheckIds.length,
|
||||
activeResourceSelection.length,
|
||||
);
|
||||
|
||||
const selectableRowCount = safeData.filter((g) =>
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: g.resourcesFail,
|
||||
@@ -146,16 +302,14 @@ export function FindingsGroupTable({
|
||||
handleCollapse();
|
||||
return;
|
||||
}
|
||||
setExpandedCheckId(checkId);
|
||||
setExpandedGroup(group);
|
||||
setSelectedExpandedCheckId(checkId);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
setSelectedExpandedCheckId(null);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
@@ -257,27 +411,55 @@ export function FindingsGroupTable({
|
||||
|
||||
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedCheckIds.length + resourceSelection.length}
|
||||
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
|
||||
label={buildMuteLabel(
|
||||
selectedCount={
|
||||
selectedCheckIds.length + activeResourceSelection.length
|
||||
}
|
||||
selectedFindingIds={[...selectedCheckIds, ...activeResourceSelection]}
|
||||
label={buildSelectionSummary(
|
||||
selectedCheckIds.length,
|
||||
resourceSelection.length,
|
||||
activeResourceSelection.length,
|
||||
)}
|
||||
muteLabel={buildMuteActionLabel(
|
||||
selectedCheckIds.length,
|
||||
activeResourceSelection.length,
|
||||
)}
|
||||
onBeforeOpen={async () => {
|
||||
const [groupIds, resourceIds] = await Promise.all([
|
||||
selectedCheckIds.length > 0
|
||||
? resolveGroupMuteIds(selectedCheckIds)
|
||||
: Promise.resolve([]),
|
||||
Promise.resolve(hasResourceSelection ? resourceSelection : []),
|
||||
Promise.resolve(
|
||||
hasResourceSelection ? activeResourceSelection : [],
|
||||
),
|
||||
]);
|
||||
return [...groupIds, ...resourceIds];
|
||||
}}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation={
|
||||
selectedCheckIds.length > 0 || resourceSelection.length > 1
|
||||
selectedCheckIds.length > 0 || activeResourceSelection.length > 1
|
||||
}
|
||||
showSendToJira={hasJiraTargets}
|
||||
canSendToJira={canSendToJira}
|
||||
sendToJiraLabel={sendToJiraLabel}
|
||||
onSendToJira={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canSendToJira && jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingTitle={jiraTitle}
|
||||
selection={jiraSelection}
|
||||
defaultDispatchMode={jiraDispatchMode}
|
||||
isFindingGroupSelection={
|
||||
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
|
||||
}
|
||||
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
|
||||
selectedResourceCount={selectedJiraResourceCount}
|
||||
description={jiraDescription}
|
||||
/>
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,11 +91,7 @@ function ResourceSkeletonRow({
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-5 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Status */}
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-6 w-11 rounded-md" />
|
||||
</TableCell>
|
||||
{/* Resource: name + uid */}
|
||||
{/* Affected failing resource: name + uid */}
|
||||
<TableCell className={cellClassName}>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
|
||||
+19
-8
@@ -73,12 +73,14 @@ import {
|
||||
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
|
||||
import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
@@ -411,6 +413,9 @@ export function ResourceDetailDrawerContent({
|
||||
// During carousel navigation we only trust row-backed data until the next
|
||||
// finding payload is fully ready, otherwise stale details flash briefly.
|
||||
const f = isNavigating ? null : currentFinding;
|
||||
const jiraSelection = f
|
||||
? createJiraTargetSelection([f.id], JIRA_DISPATCH_TARGET.FINDING_ID)
|
||||
: null;
|
||||
const isCheckMetaFresh =
|
||||
!currentResource?.checkId || currentResource.checkId === checkMeta.checkId;
|
||||
const showCheckMetaContent = !isNavigating || isCheckMetaFresh;
|
||||
@@ -545,11 +550,11 @@ export function ResourceDetailDrawerContent({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f && (
|
||||
{f && jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={f.id}
|
||||
selection={jiraSelection}
|
||||
findingTitle={checkMeta.checkTitle}
|
||||
/>
|
||||
)}
|
||||
@@ -1569,6 +1574,10 @@ function OtherFindingRow({
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
||||
const jiraSelection = createJiraTargetSelection(
|
||||
[finding.id],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
);
|
||||
|
||||
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
|
||||
|
||||
@@ -1585,12 +1594,14 @@ function OtherFindingRow({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={finding.id}
|
||||
findingTitle={finding.checkTitle}
|
||||
/>
|
||||
{jiraSelection && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
selection={jiraSelection}
|
||||
findingTitle={finding.checkTitle}
|
||||
/>
|
||||
)}
|
||||
<TableRow
|
||||
className="group cursor-pointer"
|
||||
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
||||
|
||||
@@ -409,6 +409,9 @@ export const ResourceDetailContent = ({
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
muteLabel={`Mute ${selectedFindingIds.length} ${
|
||||
selectedFindingIds.length === 1 ? "Finding" : "Findings"
|
||||
}`}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CustomRadioProps {
|
||||
ariaLabel?: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomRadio = ({ value, children }: CustomRadioProps) => {
|
||||
export const CustomRadio = ({
|
||||
ariaLabel,
|
||||
value,
|
||||
children,
|
||||
}: CustomRadioProps) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
@@ -18,7 +23,7 @@ export const CustomRadio = ({ value, children }: CustomRadioProps) => {
|
||||
"has-[[data-state=checked]]:border-button-primary",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value={value || ""} />
|
||||
<RadioGroupItem value={value || ""} aria-label={ariaLabel} />
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ComponentProps, ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -104,6 +106,10 @@ interface ActionDropdownItemProps
|
||||
description?: string;
|
||||
/** Whether the item is destructive (danger styling) */
|
||||
destructive?: boolean;
|
||||
/** Tooltip shown while the item remains interactive. */
|
||||
tooltip?: string;
|
||||
/** Tooltip shown when the item is disabled. */
|
||||
disabledTooltip?: string;
|
||||
}
|
||||
|
||||
export function ActionDropdownItem({
|
||||
@@ -112,9 +118,13 @@ export function ActionDropdownItem({
|
||||
description,
|
||||
destructive = false,
|
||||
className,
|
||||
tooltip,
|
||||
disabledTooltip,
|
||||
disabled,
|
||||
onSelect,
|
||||
...props
|
||||
}: ActionDropdownItemProps) {
|
||||
return (
|
||||
const item = (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
|
||||
@@ -122,6 +132,16 @@ export function ActionDropdownItem({
|
||||
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
|
||||
className,
|
||||
)}
|
||||
aria-disabled={disabled || undefined}
|
||||
disabled={disabled && !disabledTooltip}
|
||||
onSelect={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
@@ -149,6 +169,19 @@ export function ActionDropdownItem({
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined);
|
||||
|
||||
if (tooltipContent) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{item}</TooltipTrigger>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function ActionDropdownDangerZone({
|
||||
|
||||
@@ -116,6 +116,38 @@ describe("CloudUpgradeModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the contextual Jira dispatch upgrade", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
useCloudUpgradeStore
|
||||
.getState()
|
||||
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
|
||||
|
||||
// When
|
||||
render(<CloudUpgradeModal />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("dialog", {
|
||||
name: "Send Findings to Jira at Scale",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Send Findings to Jira in Prowler Cloud",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=jira-dispatch",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "View Plans & Pricing" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=jira-dispatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("closes the active upgrade and returns focus to its trigger", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
@@ -8,17 +8,20 @@ import {
|
||||
CROSS_PROVIDER_PDF_TASK_KIND,
|
||||
crossProviderPdfHandler,
|
||||
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
|
||||
import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import {
|
||||
registerTaskKindHandler,
|
||||
resumePendingTasks,
|
||||
} from "@/store/task-watcher/store";
|
||||
import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
|
||||
|
||||
// Kind registrations happen at module scope, before any task can settle in
|
||||
// this tab. Adding a new watched task kind (integration tests, scan exports,
|
||||
// …) is one line here plus a handler next to the feature that owns it.
|
||||
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
|
||||
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
|
||||
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
|
||||
|
||||
/**
|
||||
* Mounted once in the app layout (next to `Toaster`): resumes polling any
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
toast as shadcnToast,
|
||||
Toaster as ShadcnToaster,
|
||||
} from "@/components/shadcn/toast";
|
||||
import { toast as uiToast, Toaster as UiToaster } from "@/components/ui/toast";
|
||||
|
||||
describe("components/ui/toast", () => {
|
||||
it("uses the mounted shadcn toast store and provider", () => {
|
||||
expect(uiToast).toBe(shadcnToast);
|
||||
expect(UiToaster).toBe(ShadcnToaster);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,25 @@ describe("useFilterBatch", () => {
|
||||
"filter[delta]": ["new"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse filter[check_id] from grouped finding deep links", () => {
|
||||
// Given - URL produced by the grouped finding resources panel deep link.
|
||||
setSearchParams({
|
||||
"filter[check_id]": "teams_external_users_can_join",
|
||||
expandedCheckId: "teams_external_users_can_join",
|
||||
});
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then - expandedCheckId is not a filter chip, but check_id is URL-backed.
|
||||
expect(result.current.pendingFilters).toEqual({
|
||||
"filter[check_id]": ["teams_external_users_can_join"],
|
||||
});
|
||||
expect(result.current.getFilterValue("filter[check_id]")).toEqual([
|
||||
"teams_external_users_can_join",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Excluded keys ──────────────────────────────────────────────────────────
|
||||
@@ -179,6 +198,30 @@ describe("useFilterBatch", () => {
|
||||
// Then
|
||||
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should replace exclusive legacy filters when the new grouped filter is edited", () => {
|
||||
// Given - a legacy deep link with the exact check_id filter applied.
|
||||
setSearchParams({
|
||||
"filter[check_id]": "teams_external_users_can_join",
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFilterBatch({
|
||||
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
|
||||
}),
|
||||
);
|
||||
|
||||
// When - the Finding Group multi-select writes the __in filter.
|
||||
act(() => {
|
||||
result.current.setPending("filter[check_id__in]", [
|
||||
"teams_external_users_cannot_join",
|
||||
]);
|
||||
});
|
||||
|
||||
// Then - the stale exact filter is removed from pending state.
|
||||
expect(result.current.pendingFilters).toEqual({
|
||||
"filter[check_id__in]": ["teams_external_users_cannot_join"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getFilterValue ─────────────────────────────────────────────────────────
|
||||
@@ -227,6 +270,24 @@ describe("useFilterBatch", () => {
|
||||
// Then
|
||||
expect(values).toEqual(["critical"]);
|
||||
});
|
||||
|
||||
it("should expose legacy exclusive filter values through the replacement key", () => {
|
||||
// Given - a legacy grouped finding deep link uses filter[check_id].
|
||||
setSearchParams({
|
||||
"filter[check_id]": "teams_external_users_can_join",
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFilterBatch({
|
||||
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
|
||||
}),
|
||||
);
|
||||
|
||||
// When - the new Finding Group control asks for filter[check_id__in].
|
||||
const values = result.current.getFilterValue("filter[check_id__in]");
|
||||
|
||||
// Then - the existing exact value appears selected in the control.
|
||||
expect(values).toEqual(["teams_external_users_can_join"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasChanges & changeCount ───────────────────────────────────────────────
|
||||
|
||||
@@ -151,6 +151,23 @@ export interface UseFilterBatchOptions {
|
||||
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
|
||||
*/
|
||||
defaultParams?: Record<string, string>;
|
||||
/**
|
||||
* Filter keys that represent the same logical control. Updating one removes
|
||||
* the others so legacy exact params and new multi-select params cannot be
|
||||
* applied together.
|
||||
*/
|
||||
exclusiveFilterGroups?: string[][];
|
||||
}
|
||||
|
||||
function normalizeFilterKey(key: string): string {
|
||||
return key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
}
|
||||
|
||||
function getExclusiveFilterGroup(
|
||||
filterKey: string,
|
||||
exclusiveFilterGroups: string[][] | undefined,
|
||||
): string[] | undefined {
|
||||
return exclusiveFilterGroups?.find((group) => group.includes(filterKey));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +204,27 @@ export const useFilterBatch = (
|
||||
}, [searchParams]);
|
||||
|
||||
const setPending = (key: string, values: string[]) => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
[filterKey]: values,
|
||||
}));
|
||||
const filterKey = normalizeFilterKey(key);
|
||||
const exclusiveGroup = getExclusiveFilterGroup(
|
||||
filterKey,
|
||||
options?.exclusiveFilterGroups,
|
||||
);
|
||||
setPendingFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
exclusiveGroup
|
||||
?.filter((exclusiveKey) => exclusiveKey !== filterKey)
|
||||
.forEach((exclusiveKey) => {
|
||||
delete next[exclusiveKey];
|
||||
});
|
||||
|
||||
next[filterKey] = values;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removePending = (key: string) => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
const filterKey = normalizeFilterKey(key);
|
||||
setPendingFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[filterKey];
|
||||
@@ -265,7 +294,7 @@ export const useFilterBatch = (
|
||||
};
|
||||
|
||||
const removeAppliedAndApply = (key: string, value?: string) => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
const filterKey = normalizeFilterKey(key);
|
||||
const applied = deriveAppliedFromUrl(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
@@ -286,8 +315,20 @@ export const useFilterBatch = (
|
||||
};
|
||||
|
||||
const getFilterValue = (key: string): string[] => {
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
return pendingFilters[filterKey] ?? [];
|
||||
const filterKey = normalizeFilterKey(key);
|
||||
const values = pendingFilters[filterKey];
|
||||
if (values) return values;
|
||||
|
||||
const exclusiveGroup = getExclusiveFilterGroup(
|
||||
filterKey,
|
||||
options?.exclusiveFilterGroups,
|
||||
);
|
||||
const alternateKey = exclusiveGroup?.find(
|
||||
(exclusiveKey) =>
|
||||
exclusiveKey !== filterKey && pendingFilters[exclusiveKey],
|
||||
);
|
||||
|
||||
return alternateKey ? pendingFilters[alternateKey] : [];
|
||||
};
|
||||
|
||||
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("cloud upgrade content", () => {
|
||||
"Bring CLI Findings into One Cloud View",
|
||||
"See Compliance Across Every Provider",
|
||||
"Coordinate Finding Remediation",
|
||||
"Send Findings to Jira at Scale",
|
||||
"Use The Agent Cloud Defender",
|
||||
"Scale Prowler Without Operating It",
|
||||
"Configure Every Scan Once",
|
||||
@@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => {
|
||||
"cross-provider-compliance",
|
||||
],
|
||||
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
|
||||
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
|
||||
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
|
||||
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
|
||||
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
|
||||
|
||||
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
|
||||
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
|
||||
"cross-provider-compliance",
|
||||
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
|
||||
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
|
||||
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
|
||||
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
|
||||
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
|
||||
@@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = {
|
||||
],
|
||||
primaryCta: "Triage Findings in Prowler Cloud",
|
||||
},
|
||||
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: {
|
||||
title: "Send Findings to Jira at Scale",
|
||||
description:
|
||||
"Create Jira issues from selected findings and finding groups without handling each item separately.",
|
||||
benefits: [
|
||||
"Send selected findings or finding groups in one action",
|
||||
"Choose between grouped and individual Jira issues",
|
||||
"Track dispatch progress and retry failed findings",
|
||||
],
|
||||
primaryCta: "Send Findings to Jira in Prowler Cloud",
|
||||
},
|
||||
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
|
||||
title: "Use The Agent Cloud Defender",
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const importFresh = async () => {
|
||||
vi.resetModules();
|
||||
return import("./deployment");
|
||||
};
|
||||
|
||||
describe("enterprise feature flags", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("should keep grouped Jira dispatch disabled by default", async () => {
|
||||
// Given / When
|
||||
const { isGroupedJiraDispatchEnabled } = await importFresh();
|
||||
|
||||
// Then
|
||||
expect(isGroupedJiraDispatchEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should enable grouped Jira dispatch from the enterprise env flag in cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "cloud");
|
||||
vi.stubEnv(
|
||||
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
|
||||
"true",
|
||||
);
|
||||
|
||||
// When
|
||||
const { isGroupedJiraDispatchEnabled } = await importFresh();
|
||||
|
||||
// Then
|
||||
expect(isGroupedJiraDispatchEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep grouped Jira dispatch disabled outside cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "onpremise");
|
||||
vi.stubEnv(
|
||||
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
|
||||
"true",
|
||||
);
|
||||
|
||||
// When
|
||||
const { isGroupedJiraDispatchEnabled } = await importFresh();
|
||||
|
||||
// Then
|
||||
expect(isGroupedJiraDispatchEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const DEPLOYMENT_MODE = {
|
||||
CLOUD: "cloud",
|
||||
ON_PREMISE: "onpremise",
|
||||
} as const;
|
||||
|
||||
export const ENTERPRISE_FEATURE_ENV = {
|
||||
GROUPED_JIRA_DISPATCH_ENABLED:
|
||||
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
|
||||
} as const;
|
||||
|
||||
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
|
||||
|
||||
export type DeploymentMode =
|
||||
(typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE];
|
||||
|
||||
type EnterpriseFeatureEnv =
|
||||
(typeof ENTERPRISE_FEATURE_ENV)[keyof typeof ENTERPRISE_FEATURE_ENV];
|
||||
|
||||
const getEnterpriseFeatureValue = (
|
||||
envName: EnterpriseFeatureEnv,
|
||||
): string | undefined => {
|
||||
if (envName === ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED) {
|
||||
return process.env
|
||||
.NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getBooleanEnv = (
|
||||
envName: EnterpriseFeatureEnv,
|
||||
defaultValue: boolean,
|
||||
): boolean => {
|
||||
const value = getEnterpriseFeatureValue(envName);
|
||||
|
||||
if (value === undefined || value === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return value === "true";
|
||||
};
|
||||
|
||||
export const getDeploymentMode = (): DeploymentMode | undefined => {
|
||||
const mode = process.env.NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE;
|
||||
|
||||
if (mode === DEPLOYMENT_MODE.CLOUD || mode === DEPLOYMENT_MODE.ON_PREMISE) {
|
||||
return mode;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isGroupedJiraDispatchEnabled = (): boolean =>
|
||||
getDeploymentMode() === DEPLOYMENT_MODE.CLOUD &&
|
||||
getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false);
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { JiraDispatchTaskResult } from "@/types/integrations";
|
||||
import type { TaskState } from "@/types/tasks";
|
||||
|
||||
export interface JiraDispatchSuccessOutcome {
|
||||
success: true;
|
||||
message: string;
|
||||
warning?: string;
|
||||
failedFindingIds?: string[];
|
||||
}
|
||||
|
||||
export interface JiraDispatchFailureOutcome {
|
||||
success: false;
|
||||
error: string;
|
||||
failedFindingIds?: string[];
|
||||
}
|
||||
|
||||
export type JiraDispatchOutcome =
|
||||
| JiraDispatchSuccessOutcome
|
||||
| JiraDispatchFailureOutcome;
|
||||
|
||||
const getArrayCount = (value: unknown[] | undefined) =>
|
||||
Array.isArray(value) ? value.length : 0;
|
||||
|
||||
const getFailedCount = (result: JiraDispatchTaskResult | undefined) => {
|
||||
if (!result) return 0;
|
||||
return Math.max(
|
||||
result.failed_count ?? 0,
|
||||
getArrayCount(result.failed_groups),
|
||||
getArrayCount(result.failed_batches),
|
||||
getArrayCount(result.failed_finding_ids),
|
||||
);
|
||||
};
|
||||
|
||||
export const getJiraDispatchSuccessCount = (
|
||||
result: JiraDispatchTaskResult | undefined,
|
||||
) => {
|
||||
if (!result) return 0;
|
||||
|
||||
const createdCount = Math.max(
|
||||
result.created_count ?? 0,
|
||||
getArrayCount(result.created_issues),
|
||||
);
|
||||
const updatedCount = Math.max(
|
||||
result.updated_count ?? 0,
|
||||
getArrayCount(result.updated_issues),
|
||||
);
|
||||
|
||||
return Math.max(
|
||||
result.successful_count ?? 0,
|
||||
createdCount + updatedCount,
|
||||
result.issue_key || result.issue_url ? 1 : 0,
|
||||
);
|
||||
};
|
||||
|
||||
const ensureSentence = (message: string) =>
|
||||
/[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`;
|
||||
|
||||
const buildFailureMessage = (
|
||||
result: JiraDispatchTaskResult | undefined,
|
||||
failedCount: number,
|
||||
) => {
|
||||
const successCount = getJiraDispatchSuccessCount(result);
|
||||
const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
|
||||
|
||||
return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary;
|
||||
};
|
||||
|
||||
const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => {
|
||||
const successCount = getJiraDispatchSuccessCount(result);
|
||||
if (successCount > 1) {
|
||||
return `${successCount} Jira issues were created or updated successfully.`;
|
||||
}
|
||||
|
||||
return "Finding successfully sent to Jira!";
|
||||
};
|
||||
|
||||
const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) =>
|
||||
Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? []));
|
||||
|
||||
const withFailedFindingIds = (failedFindingIds: string[]) =>
|
||||
failedFindingIds.length > 0 ? { failedFindingIds } : {};
|
||||
|
||||
export const evaluateJiraDispatchTask = (
|
||||
state: TaskState,
|
||||
result: JiraDispatchTaskResult | null | undefined,
|
||||
): JiraDispatchOutcome => {
|
||||
const jiraResult = result ?? undefined;
|
||||
const failedFindingIds = getFailedFindingIds(jiraResult);
|
||||
|
||||
if (state === "completed") {
|
||||
const failedCount = getFailedCount(jiraResult);
|
||||
if (failedCount > 0) {
|
||||
const successCount = getJiraDispatchSuccessCount(jiraResult);
|
||||
if (successCount > 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: buildSuccessMessage(jiraResult),
|
||||
warning: buildFailureMessage(jiraResult, failedCount),
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: buildFailureMessage(jiraResult, failedCount),
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
};
|
||||
}
|
||||
|
||||
if (jiraResult?.success === false || jiraResult?.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: jiraResult.error || "Failed to create Jira issue.",
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
};
|
||||
}
|
||||
|
||||
if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Jira dispatch completed but did not create or update any issues.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: buildSuccessMessage(jiraResult),
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "failed") {
|
||||
return {
|
||||
success: false,
|
||||
error: jiraResult?.error || "Task failed.",
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: `Unknown task state: ${state}` };
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
JIRA_TARGET_SELECTION_KIND,
|
||||
type JiraBatchSelection,
|
||||
type JiraDispatchTarget,
|
||||
type JiraDispatchTargetBatch,
|
||||
type JiraSelection,
|
||||
type NonEmptyStringArray,
|
||||
} from "@/types/integrations";
|
||||
|
||||
export interface JiraDispatchTargetBatchInput {
|
||||
targetIds: string[];
|
||||
targetType: JiraDispatchTarget;
|
||||
dispatchMode?: JiraDispatchTargetBatch["dispatchMode"];
|
||||
}
|
||||
|
||||
export const toNonEmptyStringArray = (
|
||||
values: string[],
|
||||
): NonEmptyStringArray | null => {
|
||||
const [first, ...rest] = values.filter(Boolean);
|
||||
return first ? [first, ...rest] : null;
|
||||
};
|
||||
|
||||
export const createJiraTargetSelection = (
|
||||
targetIds: string[],
|
||||
targetType: JiraDispatchTarget,
|
||||
): JiraSelection | null => {
|
||||
const nonEmptyTargetIds = toNonEmptyStringArray(targetIds);
|
||||
if (!nonEmptyTargetIds) return null;
|
||||
|
||||
if (nonEmptyTargetIds.length === 1) {
|
||||
return {
|
||||
kind: JIRA_TARGET_SELECTION_KIND.SINGLE,
|
||||
targetId: nonEmptyTargetIds[0],
|
||||
targetType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST,
|
||||
targetIds: nonEmptyTargetIds,
|
||||
targetType,
|
||||
};
|
||||
};
|
||||
|
||||
export const createJiraBatchSelection = (
|
||||
batches: JiraDispatchTargetBatchInput[],
|
||||
): JiraBatchSelection | null => {
|
||||
const normalizedBatches = batches.flatMap((batch) => {
|
||||
const targetIds = toNonEmptyStringArray(batch.targetIds);
|
||||
return targetIds ? [{ ...batch, targetIds }] : [];
|
||||
});
|
||||
const [firstBatch, ...remainingBatches] = normalizedBatches;
|
||||
|
||||
return firstBatch
|
||||
? {
|
||||
kind: JIRA_TARGET_SELECTION_KIND.BATCHES,
|
||||
batches: [firstBatch, ...remainingBatches],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getJiraSelectionBatches = (
|
||||
selection: JiraSelection,
|
||||
): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => {
|
||||
if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) {
|
||||
return selection.batches;
|
||||
}
|
||||
|
||||
if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) {
|
||||
return [
|
||||
{
|
||||
targetIds: [selection.targetId],
|
||||
targetType: selection.targetType,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
targetIds: selection.targetIds,
|
||||
targetType: selection.targetType,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { WatchedTask } from "@/store/task-watcher/store";
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
type JiraDispatchMode,
|
||||
} from "@/types/integrations";
|
||||
|
||||
export interface JiraDispatchTaskMeta {
|
||||
integrationId: string;
|
||||
projectKey: string;
|
||||
issueType: string;
|
||||
dispatchMode: JiraDispatchMode;
|
||||
}
|
||||
|
||||
export const buildJiraDispatchTaskMeta = ({
|
||||
integrationId,
|
||||
projectKey,
|
||||
issueType,
|
||||
dispatchMode,
|
||||
}: JiraDispatchTaskMeta): Record<string, string> => ({
|
||||
integrationId,
|
||||
projectKey,
|
||||
issueType,
|
||||
dispatchMode,
|
||||
});
|
||||
|
||||
export const parseJiraDispatchTaskMeta = (
|
||||
task: WatchedTask,
|
||||
): JiraDispatchTaskMeta | null => {
|
||||
const { integrationId, projectKey, issueType, dispatchMode } = task.meta;
|
||||
if (
|
||||
!integrationId ||
|
||||
!projectKey ||
|
||||
!issueType ||
|
||||
(dispatchMode !== JIRA_DISPATCH_MODE.GROUPED &&
|
||||
dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
projectKey,
|
||||
issueType,
|
||||
dispatchMode,
|
||||
};
|
||||
};
|
||||
@@ -49,6 +49,37 @@ describe("task watcher store", () => {
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns and persists a completed task result while allowing the caller to own notifications", async () => {
|
||||
// Given
|
||||
pollMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: { created_count: 1, failed_count: 1 },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await trackAndPollTask<{
|
||||
created_count: number;
|
||||
failed_count: number;
|
||||
}>({
|
||||
taskId: "jira-task",
|
||||
kind: "test-kind",
|
||||
meta: {},
|
||||
notifyHandler: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
status: TASK_WATCHER_STATUS.READY,
|
||||
result: { created_count: 1, failed_count: 1 },
|
||||
});
|
||||
expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({
|
||||
created_count: 1,
|
||||
failed_count: 1,
|
||||
});
|
||||
expect(onReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces settled results of the same kind when tracking new work", async () => {
|
||||
// Given
|
||||
pollMock.mockResolvedValue({ ok: true, state: "completed" });
|
||||
@@ -201,6 +232,39 @@ describe("task watcher store", () => {
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes a persisted task result to its handler after resuming", async () => {
|
||||
// Given
|
||||
const taskResult = {
|
||||
created_count: 1,
|
||||
failed_count: 1,
|
||||
failed_finding_ids: ["finding-2"],
|
||||
};
|
||||
pollMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: taskResult,
|
||||
});
|
||||
useTaskWatcherStore.setState({
|
||||
tasks: {
|
||||
"resumed-jira-task": {
|
||||
taskId: "resumed-jira-task",
|
||||
kind: "test-kind",
|
||||
status: TASK_WATCHER_STATUS.PENDING,
|
||||
meta: {},
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await resumePendingTasks();
|
||||
|
||||
// Then
|
||||
expect(onReady).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ result: taskResult }),
|
||||
);
|
||||
});
|
||||
|
||||
it("discards settled tasks before resuming persisted work", async () => {
|
||||
// Given
|
||||
pollMock.mockResolvedValue({ ok: true, state: "completed" });
|
||||
|
||||
+138
-72
@@ -28,6 +28,9 @@ export interface WatchedTask {
|
||||
meta: Record<string, string>;
|
||||
startedAt: number;
|
||||
error?: string;
|
||||
/** Serializable task result. Persisted so another tab or a reload can
|
||||
* finish feature-specific handling without polling the task again. */
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface TaskKindHandler {
|
||||
@@ -35,6 +38,21 @@ export interface TaskKindHandler {
|
||||
onError: (task: WatchedTask) => void;
|
||||
}
|
||||
|
||||
export interface TaskTrackingResult<R = unknown> {
|
||||
status: TaskWatcherStatus;
|
||||
error?: string;
|
||||
result?: R;
|
||||
}
|
||||
|
||||
export interface TrackAndPollTaskInput {
|
||||
taskId: string;
|
||||
kind: string;
|
||||
meta: Record<string, string>;
|
||||
/** Let the awaiting caller aggregate notifications. If this tab reloads,
|
||||
* the persisted task resumes with its registered handler as usual. */
|
||||
notifyHandler?: boolean;
|
||||
}
|
||||
|
||||
interface TaskWatcherState {
|
||||
tasks: Record<string, WatchedTask>;
|
||||
upsertTask: (task: WatchedTask) => void;
|
||||
@@ -42,6 +60,7 @@ interface TaskWatcherState {
|
||||
taskId: string,
|
||||
status: TaskWatcherStatus,
|
||||
error?: string,
|
||||
result?: unknown,
|
||||
) => void;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
@@ -69,12 +88,15 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
|
||||
tasks: {},
|
||||
upsertTask: (task) =>
|
||||
set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })),
|
||||
resolveTask: (taskId, status, error) =>
|
||||
resolveTask: (taskId, status, error, result) =>
|
||||
set((state) => {
|
||||
const task = state.tasks[taskId];
|
||||
if (!task) return state;
|
||||
return {
|
||||
tasks: { ...state.tasks, [taskId]: { ...task, status, error } },
|
||||
tasks: {
|
||||
...state.tasks,
|
||||
[taskId]: { ...task, status, error, result },
|
||||
},
|
||||
};
|
||||
}),
|
||||
dismissTask: (taskId) =>
|
||||
@@ -92,25 +114,35 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
|
||||
|
||||
// In-memory only: poll loops alive in THIS tab. Never persisted, so a reload
|
||||
// naturally re-enters through resumePendingTasks without double-polling.
|
||||
const activePolls = new Set<string>();
|
||||
const activePolls = new Map<string, Promise<TaskTrackingResult<unknown>>>();
|
||||
const suppressedHandlers = new Set<string>();
|
||||
|
||||
const settleTask = (
|
||||
taskId: string,
|
||||
status: TaskWatcherStatus,
|
||||
error?: string,
|
||||
) => {
|
||||
result?: unknown,
|
||||
): TaskTrackingResult => {
|
||||
const store = useTaskWatcherStore.getState();
|
||||
const currentTask = store.tasks[taskId];
|
||||
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
|
||||
return;
|
||||
return {
|
||||
status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR,
|
||||
...(currentTask?.error ? { error: currentTask.error } : {}),
|
||||
...(currentTask?.result !== undefined
|
||||
? { result: currentTask.result }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
store.resolveTask(taskId, status, error);
|
||||
store.resolveTask(taskId, status, error, result);
|
||||
const task = useTaskWatcherStore.getState().tasks[taskId];
|
||||
if (!task) return;
|
||||
if (!task) {
|
||||
return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." };
|
||||
}
|
||||
|
||||
const handler = handlers.get(task.kind);
|
||||
if (handler) {
|
||||
if (handler && !suppressedHandlers.has(taskId)) {
|
||||
if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task);
|
||||
else handler.onError(task);
|
||||
}
|
||||
@@ -120,107 +152,141 @@ const settleTask = (
|
||||
if (status === TASK_WATCHER_STATUS.ERROR) {
|
||||
store.dismissTask(taskId);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
...(error ? { error } : {}),
|
||||
...(result !== undefined ? { result } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const runPollLoop = async (taskId: string): Promise<void> => {
|
||||
const runPollLoop = async <R>(
|
||||
taskId: string,
|
||||
): Promise<TaskTrackingResult<R>> => {
|
||||
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
|
||||
const result = await pollTaskUntilSettled(taskId);
|
||||
const result = await pollTaskUntilSettled<R>(taskId);
|
||||
|
||||
if (result.ok) {
|
||||
if (result.state === "completed") {
|
||||
settleTask(taskId, TASK_WATCHER_STATUS.READY);
|
||||
return settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.READY,
|
||||
undefined,
|
||||
result.result,
|
||||
) as TaskTrackingResult<R>;
|
||||
} else {
|
||||
settleTask(
|
||||
return settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.ERROR,
|
||||
`Task ended in state "${result.state}".`,
|
||||
);
|
||||
result.result,
|
||||
) as TaskTrackingResult<R>;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// "Task timeout" just means this server round expired while the task
|
||||
// is still running — keep polling. Real errors settle immediately.
|
||||
if (result.error !== "Task timeout") {
|
||||
settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error);
|
||||
return;
|
||||
return settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.ERROR,
|
||||
result.error,
|
||||
result.result,
|
||||
) as TaskTrackingResult<R>;
|
||||
}
|
||||
}
|
||||
|
||||
settleTask(
|
||||
return settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.ERROR,
|
||||
"The task is taking too long. Try again later.",
|
||||
);
|
||||
) as TaskTrackingResult<R>;
|
||||
};
|
||||
|
||||
const pollUntilDone = async (taskId: string): Promise<void> => {
|
||||
if (activePolls.has(taskId)) return;
|
||||
activePolls.add(taskId);
|
||||
const pollUntilDone = <R>(taskId: string): Promise<TaskTrackingResult<R>> => {
|
||||
const existingPoll = activePolls.get(taskId);
|
||||
if (existingPoll) return existingPoll as Promise<TaskTrackingResult<R>>;
|
||||
|
||||
try {
|
||||
const runIfPending = async () => {
|
||||
// A different tab may have completed the task while this one waited for
|
||||
// the cross-tab lock. Refresh persisted state before polling or notifying.
|
||||
await useTaskWatcherStore.persist.rehydrate();
|
||||
const task = useTaskWatcherStore.getState().tasks[taskId];
|
||||
if (task?.status !== TASK_WATCHER_STATUS.PENDING) return;
|
||||
await runPollLoop(taskId);
|
||||
};
|
||||
const pollPromise = (async (): Promise<TaskTrackingResult<R>> => {
|
||||
try {
|
||||
const runIfPending = async (): Promise<TaskTrackingResult<R>> => {
|
||||
// A different tab may have completed the task while this one waited for
|
||||
// the cross-tab lock. Refresh persisted state before polling or notifying.
|
||||
await useTaskWatcherStore.persist.rehydrate();
|
||||
const task = useTaskWatcherStore.getState().tasks[taskId];
|
||||
if (task?.status !== TASK_WATCHER_STATUS.PENDING) {
|
||||
return {
|
||||
status: task?.status ?? TASK_WATCHER_STATUS.ERROR,
|
||||
...(task?.error ? { error: task.error } : {}),
|
||||
...(task?.result !== undefined ? { result: task.result as R } : {}),
|
||||
};
|
||||
}
|
||||
return runPollLoop<R>(taskId);
|
||||
};
|
||||
|
||||
if (typeof navigator !== "undefined" && navigator.locks) {
|
||||
await navigator.locks.request(`task-watcher:${taskId}`, runIfPending);
|
||||
} else {
|
||||
await runIfPending();
|
||||
if (typeof navigator !== "undefined" && navigator.locks) {
|
||||
return await navigator.locks.request(
|
||||
`task-watcher:${taskId}`,
|
||||
runIfPending,
|
||||
);
|
||||
}
|
||||
|
||||
return await runIfPending();
|
||||
} catch {
|
||||
// A thrown poll (e.g. the server-action RPC failing on a network drop)
|
||||
// must still settle the task, or it stays PENDING in the persisted
|
||||
// store and blocks the UI until the staleness ceiling.
|
||||
return settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.ERROR,
|
||||
"Tracking the task failed unexpectedly. Try again later.",
|
||||
) as TaskTrackingResult<R>;
|
||||
} finally {
|
||||
activePolls.delete(taskId);
|
||||
}
|
||||
} catch {
|
||||
// A thrown poll (e.g. the server-action RPC failing on a network drop)
|
||||
// must still settle the task, or it stays PENDING in the persisted
|
||||
// store and blocks the UI until the staleness ceiling.
|
||||
settleTask(
|
||||
taskId,
|
||||
TASK_WATCHER_STATUS.ERROR,
|
||||
"Tracking the task failed unexpectedly. Try again later.",
|
||||
);
|
||||
} finally {
|
||||
activePolls.delete(taskId);
|
||||
}
|
||||
})();
|
||||
|
||||
activePolls.set(taskId, pollPromise);
|
||||
return pollPromise;
|
||||
};
|
||||
|
||||
/** Track a freshly dispatched backend task and poll it to completion. The
|
||||
* poll loop lives at module scope (fired from the click handler), so it
|
||||
* survives client-side navigation without any effect subscriptions. */
|
||||
export const trackAndPollTask = async ({
|
||||
export const trackAndPollTask = async <R = unknown>({
|
||||
taskId,
|
||||
kind,
|
||||
meta,
|
||||
}: {
|
||||
taskId: string;
|
||||
kind: string;
|
||||
meta: Record<string, string>;
|
||||
}): Promise<void> => {
|
||||
notifyHandler = true,
|
||||
}: TrackAndPollTaskInput): Promise<TaskTrackingResult<R>> => {
|
||||
if (!notifyHandler) suppressedHandlers.add(taskId);
|
||||
|
||||
const existing = useTaskWatcherStore.getState().tasks[taskId];
|
||||
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
|
||||
return pollUntilDone(taskId);
|
||||
try {
|
||||
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
|
||||
return await pollUntilDone<R>(taskId);
|
||||
}
|
||||
|
||||
const store = useTaskWatcherStore.getState();
|
||||
Object.values(store.tasks)
|
||||
.filter(
|
||||
(task) =>
|
||||
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
|
||||
)
|
||||
.forEach((task) => store.dismissTask(task.taskId));
|
||||
|
||||
store.upsertTask({
|
||||
taskId,
|
||||
kind,
|
||||
status: TASK_WATCHER_STATUS.PENDING,
|
||||
meta,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
return await pollUntilDone<R>(taskId);
|
||||
} finally {
|
||||
suppressedHandlers.delete(taskId);
|
||||
}
|
||||
|
||||
const store = useTaskWatcherStore.getState();
|
||||
Object.values(store.tasks)
|
||||
.filter(
|
||||
(task) =>
|
||||
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
|
||||
)
|
||||
.forEach((task) => store.dismissTask(task.taskId));
|
||||
|
||||
store.upsertTask({
|
||||
taskId,
|
||||
kind,
|
||||
status: TASK_WATCHER_STATUS.PENDING,
|
||||
meta,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
return pollUntilDone(taskId);
|
||||
};
|
||||
|
||||
/** Resume polling every persisted pending task after a hard reload; tasks
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
|
||||
CLI_IMPORT: "cli_import",
|
||||
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
|
||||
FINDING_TRIAGE: "finding_triage",
|
||||
JIRA_DISPATCH: "jira_dispatch",
|
||||
LIGHTHOUSE_AI: "lighthouse_ai",
|
||||
GENERAL: "general",
|
||||
SCAN_CONFIGURATION: "scan_configuration",
|
||||
|
||||
@@ -4,6 +4,63 @@ import type { TaskState } from "@/types/tasks";
|
||||
|
||||
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
|
||||
|
||||
export const JIRA_DISPATCH_MODE = {
|
||||
INDIVIDUAL: "individual",
|
||||
GROUPED: "grouped",
|
||||
} as const;
|
||||
|
||||
export type JiraDispatchMode =
|
||||
(typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE];
|
||||
|
||||
export const JIRA_DISPATCH_TARGET = {
|
||||
CHECK_ID: "check_id",
|
||||
FINDING_ID: "finding_id",
|
||||
} as const;
|
||||
|
||||
export type JiraDispatchTarget =
|
||||
(typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET];
|
||||
|
||||
export const JIRA_TARGET_SELECTION_KIND = {
|
||||
SINGLE: "single",
|
||||
TARGET_LIST: "target-list",
|
||||
BATCHES: "batches",
|
||||
} as const;
|
||||
|
||||
export type JiraTargetSelectionKind =
|
||||
(typeof JIRA_TARGET_SELECTION_KIND)[keyof typeof JIRA_TARGET_SELECTION_KIND];
|
||||
|
||||
export type NonEmptyStringArray = [string, ...string[]];
|
||||
|
||||
export interface JiraDispatchTargetBatch {
|
||||
targetIds: NonEmptyStringArray;
|
||||
targetType: JiraDispatchTarget;
|
||||
dispatchMode?: JiraDispatchMode;
|
||||
}
|
||||
|
||||
export interface JiraSingleTargetSelection {
|
||||
kind: typeof JIRA_TARGET_SELECTION_KIND.SINGLE;
|
||||
targetId: string;
|
||||
targetType: JiraDispatchTarget;
|
||||
}
|
||||
|
||||
export interface JiraTargetListSelection {
|
||||
kind: typeof JIRA_TARGET_SELECTION_KIND.TARGET_LIST;
|
||||
targetIds: NonEmptyStringArray;
|
||||
targetType: JiraDispatchTarget;
|
||||
}
|
||||
|
||||
export interface JiraBatchSelection {
|
||||
kind: typeof JIRA_TARGET_SELECTION_KIND.BATCHES;
|
||||
batches: [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]];
|
||||
}
|
||||
|
||||
export type JiraSelection =
|
||||
| JiraSingleTargetSelection
|
||||
| JiraTargetListSelection
|
||||
| JiraBatchSelection;
|
||||
|
||||
export const JIRA_DISPATCH_TASK_KIND = "jira-dispatch";
|
||||
|
||||
export interface IntegrationProps {
|
||||
type: "integrations";
|
||||
id: string;
|
||||
@@ -45,6 +102,7 @@ export interface JiraDispatchRequest {
|
||||
attributes: {
|
||||
project_key: string;
|
||||
issue_type: string;
|
||||
dispatch_mode?: JiraDispatchMode;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -58,21 +116,30 @@ export interface JiraDispatchResponse {
|
||||
completed_at: string | null;
|
||||
name: string;
|
||||
state: TaskState;
|
||||
result: {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
issue_url?: string;
|
||||
issue_key?: string;
|
||||
created_count?: number;
|
||||
failed_count?: number;
|
||||
} | null;
|
||||
result: JiraDispatchTaskResult | null;
|
||||
task_args: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface JiraDispatchTaskResult {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
successful_count?: number;
|
||||
created_count?: number;
|
||||
updated_count?: number;
|
||||
failed_count?: number;
|
||||
created_issues?: unknown[];
|
||||
updated_issues?: unknown[];
|
||||
failed_groups?: unknown[];
|
||||
failed_batches?: unknown[];
|
||||
failed_finding_ids?: string[];
|
||||
issue_url?: string;
|
||||
issue_key?: string;
|
||||
}
|
||||
|
||||
// Shared AWS credential fields schema
|
||||
const awsCredentialFields = {
|
||||
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),
|
||||
|
||||
Reference in New Issue
Block a user