chore: merge master and resolve task watcher conflict

This commit is contained in:
alejandrobailo
2026-07-22 14:19:12 +02:00
49 changed files with 5779 additions and 565 deletions
@@ -0,0 +1 @@
Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped
+150 -20
View File
@@ -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'} "
+646 -22
View File
@@ -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"}
+2
View File
@@ -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",
+149 -64
View File
@@ -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.",
});
});
});
+47 -29
View File
@@ -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,
);
};
+13
View File
@@ -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");
});
});
+67
View File
@@ -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
+28 -6
View File
@@ -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();
});
});
+82 -14
View File
@@ -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.",
}),
),
);
});
});
+480 -192
View File
@@ -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" />
@@ -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}
/>
)}
+7 -2
View File
@@ -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
+14
View File
@@ -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);
});
});
+61
View File
@@ -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 ───────────────────────────────────────────────
+50 -9
View File
@@ -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);
+2
View File
@@ -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"],
+12
View File
@@ -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:
+50
View File
@@ -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);
});
});
+55
View File
@@ -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);
+141
View File
@@ -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}` };
};
+84
View File
@@ -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,
},
];
};
+46
View File
@@ -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,
};
};
+64
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+76 -9
View File
@@ -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"]),