mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca0d283f6 | ||
|
|
3935466e63 | ||
|
|
2745ad3d01 | ||
|
|
266ee5a6c6 | ||
|
|
33ec0a8ad3 | ||
|
|
8e56ee982a | ||
|
|
d71f9c9af1 | ||
|
|
3ff4aacd8d | ||
|
|
447bbd5777 |
@@ -157,7 +157,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.1
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
+13
-3
@@ -2,6 +2,19 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.33.1] (Prowler v5.32.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826)
|
||||
- Invited users now keep their invitation context when completing authentication with Google, GitHub, or SAML, so the invitation is accepted during login [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792)
|
||||
|
||||
---
|
||||
|
||||
## [1.33.0] (Prowler v5.32.0)
|
||||
|
||||
### 🚀 Added
|
||||
@@ -19,9 +32,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
|
||||
- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [1.32.1] (Prowler v5.31.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.32",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.33.0"
|
||||
version = "1.33.1"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -9,6 +9,7 @@ from api.models import (
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
@@ -20,6 +21,22 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_invitation_token(request):
|
||||
for source_name in ("data", "POST"):
|
||||
data = getattr(request, source_name, None) or {}
|
||||
if not hasattr(data, "get"):
|
||||
continue
|
||||
invitation_token = data.get("invitation_token")
|
||||
if invitation_token:
|
||||
return invitation_token
|
||||
|
||||
wrapped_request = getattr(request, "_request", None)
|
||||
if wrapped_request and wrapped_request is not request:
|
||||
return ProwlerSocialAccountAdapter._get_invitation_token(wrapped_request)
|
||||
|
||||
return None
|
||||
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
@@ -83,29 +100,38 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
invitation_token = self._get_invitation_token(request)
|
||||
if invitation_token:
|
||||
invitation, _ = accept_invitation_for_user(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
invitation_token=invitation_token,
|
||||
)
|
||||
request.prowler_invitation_token = invitation_token
|
||||
request.prowler_invitation_tenant_id = str(invitation.tenant_id)
|
||||
else:
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
else:
|
||||
request.session["saml_user_created"] = str(user.id)
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0096_attack_paths_scan_is_migrated"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="attackpathsscan",
|
||||
name="is_migrated",
|
||||
field=models.BooleanField(db_default=False, default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attackpathsscan",
|
||||
name="sink_backend",
|
||||
field=models.CharField(
|
||||
choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")],
|
||||
db_default="neo4j",
|
||||
default="neo4j",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -814,9 +814,10 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
|
||||
# still using the previous graph shape. Query catalog selection uses this
|
||||
# flag; physical read routing uses sink_backend below.
|
||||
# TODO: drop after Neptune cutover
|
||||
is_migrated = models.BooleanField(default=False)
|
||||
is_migrated = models.BooleanField(default=False, db_default=False)
|
||||
sink_backend = models.CharField(
|
||||
choices=SinkBackendChoices.choices,
|
||||
db_default=SinkBackendChoices.NEO4J,
|
||||
default=SinkBackendChoices.NEO4J,
|
||||
max_length=16,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.33.0
|
||||
version: 1.33.1
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from api.adapters import ProwlerSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.models import SAMLConfiguration
|
||||
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
@@ -188,6 +188,44 @@ class TestProwlerSocialAccountAdapter:
|
||||
_, called_user = call_args[0]
|
||||
assert called_user.email == create_test_user.email
|
||||
|
||||
def test_save_user_social_with_invitation_joins_invited_tenant(
|
||||
self, rf, create_test_user, tenants_fixture
|
||||
):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
invited_tenant = tenants_fixture[2]
|
||||
invited_email = "frank-invited@example.com"
|
||||
invitation = Invitation.objects.create(
|
||||
tenant=invited_tenant,
|
||||
email=invited_email,
|
||||
inviter=create_test_user,
|
||||
)
|
||||
request = rf.post("/", data={"invitation_token": invitation.token})
|
||||
request.session = {}
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "google"
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.extra_data = {"name": "Frank"}
|
||||
|
||||
real_user = User.objects.create_user(
|
||||
name="Frank", email=invited_email, password="Secret123!"
|
||||
)
|
||||
tenants_before = Tenant.objects.count()
|
||||
|
||||
with patch("api.adapters.super") as mock_super:
|
||||
mock_super.return_value.save_user.return_value = real_user
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.state == Invitation.State.ACCEPTED
|
||||
assert Tenant.objects.count() == tenants_before
|
||||
assert Membership.objects.filter(
|
||||
user=real_user,
|
||||
tenant=invited_tenant,
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
).exists()
|
||||
|
||||
def test_save_user_saml_sets_session_flag(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
|
||||
@@ -103,20 +103,84 @@ class TestUserViewSet:
|
||||
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
|
||||
|
||||
def test_partial_update_user_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, create_test_user
|
||||
self, authenticated_client_no_permissions_rbac, create_test_user_rbac_limited
|
||||
):
|
||||
updated_data = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(create_test_user_rbac_limited.id),
|
||||
"attributes": {"name": "Updated Name"},
|
||||
}
|
||||
}
|
||||
response = authenticated_client_no_permissions_rbac.patch(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": create_test_user_rbac_limited.id}),
|
||||
data=updated_data,
|
||||
format="vnd.api+json",
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
|
||||
|
||||
def test_partial_update_other_user_with_no_permissions_denied(
|
||||
self, authenticated_client_no_permissions_rbac, tenants_fixture
|
||||
):
|
||||
original_email = "target-rbac-update@example.com"
|
||||
original_password = "OriginalPassword123@"
|
||||
target_user = User.objects.create_user(
|
||||
name="target_rbac_update",
|
||||
email=original_email,
|
||||
password=original_password,
|
||||
)
|
||||
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
|
||||
updated_data = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(target_user.id),
|
||||
"attributes": {
|
||||
"email": "updated-target-rbac@example.com",
|
||||
"password": "UpdatedPassword123@",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.patch(
|
||||
reverse("user-detail", kwargs={"pk": target_user.id}),
|
||||
data=updated_data,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
target_user.refresh_from_db()
|
||||
assert target_user.email == original_email
|
||||
assert target_user.check_password(original_password)
|
||||
|
||||
def test_partial_update_other_user_with_manage_users_allowed(
|
||||
self, authenticated_client_rbac_manage_users_only
|
||||
):
|
||||
user = authenticated_client_rbac_manage_users_only.user
|
||||
tenant = Membership.objects.filter(user=user).first().tenant
|
||||
target_user = User.objects.create_user(
|
||||
name="target_manage_users_update",
|
||||
email="target-manage-users-update@example.com",
|
||||
password="Password123@",
|
||||
)
|
||||
Membership.objects.create(user=target_user, tenant=tenant)
|
||||
updated_data = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(target_user.id),
|
||||
"attributes": {"name": "Updated Target Name"},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_manage_users_only.patch(
|
||||
reverse("user-detail", kwargs={"pk": target_user.id}),
|
||||
data=updated_data,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
target_user.refresh_from_db()
|
||||
assert target_user.name == "Updated Target Name"
|
||||
|
||||
def test_delete_user_with_all_permissions(
|
||||
self, authenticated_client_rbac, create_test_user_rbac
|
||||
@@ -540,9 +604,7 @@ class TestLimitedVisibility:
|
||||
TEST_PASSWORD = "Thisisapassword123@"
|
||||
|
||||
@pytest.fixture
|
||||
def limited_admin_user(
|
||||
self, django_db_setup, django_db_blocker, tenants_fixture, providers_fixture
|
||||
):
|
||||
def limited_admin_user(self, django_db_blocker, tenants_fixture, providers_fixture):
|
||||
with django_db_blocker.unblock():
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
@@ -626,10 +688,10 @@ class TestLimitedVisibility:
|
||||
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overviews_providers(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
scan_summaries_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
@@ -648,6 +710,7 @@ class TestLimitedVisibility:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 0
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name",
|
||||
[
|
||||
@@ -659,7 +722,6 @@ class TestLimitedVisibility:
|
||||
self,
|
||||
endpoint_name,
|
||||
authenticated_client_rbac_limited,
|
||||
scan_summaries_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
@@ -684,10 +746,10 @@ class TestLimitedVisibility:
|
||||
data = response.json()["data"]["attributes"].values()
|
||||
assert all(value == 0 for value in data)
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overviews_services(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
scan_summaries_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
|
||||
@@ -59,7 +59,11 @@ from api.models import (
|
||||
from api.rls import Tenant
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from api.v1.serializers import TokenSerializer
|
||||
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
|
||||
from api.v1.views import (
|
||||
ComplianceOverviewViewSet,
|
||||
CustomSAMLLoginView,
|
||||
TenantFinishACSView,
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE,
|
||||
@@ -244,6 +248,63 @@ class TestUserViewSet:
|
||||
create_test_user.refresh_from_db()
|
||||
assert create_test_user.company_name == new_company_name
|
||||
|
||||
def test_users_partial_update_same_tenant_other_user_password_denied(
|
||||
self, authenticated_client_no_permissions_rbac, tenants_fixture
|
||||
):
|
||||
original_password = "OriginalPassword123@"
|
||||
new_password = "UpdatedPassword123@"
|
||||
target_user = User.objects.create_user(
|
||||
password=original_password,
|
||||
email="target-password-update@example.com",
|
||||
)
|
||||
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(target_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
},
|
||||
}
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.patch(
|
||||
reverse("user-detail", kwargs={"pk": target_user.id}),
|
||||
data=payload,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
target_user.refresh_from_db()
|
||||
assert target_user.check_password(original_password)
|
||||
assert not target_user.check_password(new_password)
|
||||
|
||||
def test_users_partial_update_same_tenant_other_user_email_denied(
|
||||
self, authenticated_client_no_permissions_rbac, tenants_fixture
|
||||
):
|
||||
original_email = "target-email-update@example.com"
|
||||
new_email = "updated-target-email@example.com"
|
||||
target_user = User.objects.create_user(
|
||||
password="OriginalPassword123@",
|
||||
email=original_email,
|
||||
)
|
||||
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(target_user.id),
|
||||
"attributes": {"email": new_email},
|
||||
},
|
||||
}
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.patch(
|
||||
reverse("user-detail", kwargs={"pk": target_user.id}),
|
||||
data=payload,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
target_user.refresh_from_db()
|
||||
assert target_user.email == original_email
|
||||
|
||||
def test_users_partial_update_invalid_content_type(
|
||||
self, authenticated_client, create_test_user
|
||||
):
|
||||
@@ -1491,13 +1552,13 @@ class TestProviderViewSet:
|
||||
("provider_groups", ["provider-groups"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("create_provider_group_relationship")
|
||||
def test_providers_list_include(
|
||||
self,
|
||||
include_values,
|
||||
expected_resources,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
create_provider_group_relationship,
|
||||
):
|
||||
response = authenticated_client.get(
|
||||
reverse("provider-list"), {"include": include_values}
|
||||
@@ -3542,7 +3603,7 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scan_json_payload, expected_scanner_args",
|
||||
"scan_json_payload, _expected_scanner_args",
|
||||
[
|
||||
# Case 1: No scanner_args in payload (should use provider's scanner_args)
|
||||
(
|
||||
@@ -3591,7 +3652,7 @@ class TestScanViewSet:
|
||||
mock_task_get,
|
||||
authenticated_client,
|
||||
scan_json_payload,
|
||||
expected_scanner_args,
|
||||
_expected_scanner_args,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
@@ -4070,7 +4131,7 @@ class TestScanViewSet:
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
presigned_url = (
|
||||
@@ -4176,7 +4237,7 @@ class TestScanViewSet:
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.TaskSerializer",
|
||||
lambda *args, **kwargs: type("S", (), {"data": dummy}),
|
||||
lambda *_args, **_kwargs: type("S", (), {"data": dummy}),
|
||||
)
|
||||
|
||||
framework = get_compliance_frameworks(scan.provider.provider)[0]
|
||||
@@ -4234,7 +4295,7 @@ class TestScanViewSet:
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
match_key = "path/compliance/mitre_attack_aws.csv"
|
||||
@@ -4245,6 +4306,7 @@ class TestScanViewSet:
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
del Prefix
|
||||
return {"Contents": [{"Key": match_key}]}
|
||||
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
@@ -4276,7 +4338,7 @@ class TestScanViewSet:
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
@@ -4284,6 +4346,7 @@ class TestScanViewSet:
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
del Prefix
|
||||
return {
|
||||
"Contents": [
|
||||
{
|
||||
@@ -4357,11 +4420,12 @@ class TestScanViewSet:
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
del Prefix
|
||||
return {"Contents": []}
|
||||
|
||||
def get_object(self, Bucket, Key):
|
||||
@@ -4547,7 +4611,7 @@ class TestScanViewSet:
|
||||
inserted_at=base + timedelta(hours=1)
|
||||
)
|
||||
|
||||
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
|
||||
mock_task_serializer.side_effect = lambda instance, *_a, **_k: SimpleNamespace(
|
||||
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
|
||||
)
|
||||
|
||||
@@ -6279,9 +6343,8 @@ class TestResourceViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_resources_metadata_retrieve(
|
||||
self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
|
||||
def test_resources_metadata_retrieve(self, authenticated_client, resources_fixture):
|
||||
resource_1, *_ = resources_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-metadata"),
|
||||
@@ -6301,8 +6364,9 @@ class TestResourceViewSet:
|
||||
assert set(data["data"]["attributes"]["types"]) == expected_resource_types
|
||||
assert set(data["data"]["attributes"]["groups"]) == expected_groups
|
||||
|
||||
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
|
||||
def test_resources_metadata_resource_filter_retrieve(
|
||||
self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
|
||||
self, authenticated_client, resources_fixture
|
||||
):
|
||||
resource_1, *_ = resources_fixture
|
||||
response = authenticated_client.get(
|
||||
@@ -7796,9 +7860,8 @@ class TestFindingViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_findings_metadata_retrieve(
|
||||
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
|
||||
def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture):
|
||||
finding_1, *_ = findings_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-metadata"),
|
||||
@@ -7821,8 +7884,9 @@ class TestFindingViewSet:
|
||||
)
|
||||
# assert data["data"]["attributes"]["tags"] == expected_tags
|
||||
|
||||
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
|
||||
def test_findings_metadata_resource_filter_retrieve(
|
||||
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
|
||||
self, authenticated_client, findings_fixture
|
||||
):
|
||||
finding_1, *_ = findings_fixture
|
||||
response = authenticated_client.get(
|
||||
@@ -8008,9 +8072,8 @@ class TestFindingViewSet:
|
||||
attributes = response.json()["data"]["attributes"]
|
||||
assert set(attributes["categories"]) == {"gen-ai", "security"}
|
||||
|
||||
def test_findings_metadata_latest_categories(
|
||||
self, authenticated_client, latest_scan_finding_with_categories
|
||||
):
|
||||
@pytest.mark.usefixtures("latest_scan_finding_with_categories")
|
||||
def test_findings_metadata_latest_categories(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-metadata_latest"),
|
||||
)
|
||||
@@ -8018,9 +8081,8 @@ class TestFindingViewSet:
|
||||
attributes = response.json()["data"]["attributes"]
|
||||
assert set(attributes["categories"]) == {"gen-ai", "iam"}
|
||||
|
||||
def test_findings_metadata_latest_groups(
|
||||
self, authenticated_client, latest_scan_finding_with_categories
|
||||
):
|
||||
@pytest.mark.usefixtures("latest_scan_finding_with_categories")
|
||||
def test_findings_metadata_latest_groups(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-metadata_latest"),
|
||||
)
|
||||
@@ -8540,16 +8602,14 @@ class TestInvitationViewSet:
|
||||
expires_at=self.TOMORROW,
|
||||
)
|
||||
|
||||
data = {
|
||||
"invitation_token": invitation.token,
|
||||
}
|
||||
data = {"invitation_token": invitation.token}
|
||||
|
||||
assert not Membership.objects.filter(
|
||||
user__email__iexact=user.email, tenant=tenant
|
||||
).exists()
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="json"
|
||||
reverse("invitation-accept"), data=data, format="vnd.api+json"
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
invitation.refresh_from_db()
|
||||
@@ -8558,13 +8618,46 @@ class TestInvitationViewSet:
|
||||
).exists()
|
||||
assert invitation.state == Invitation.State.ACCEPTED.value
|
||||
|
||||
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
|
||||
data = {
|
||||
"invitation_token": "invalid_token",
|
||||
}
|
||||
def test_invitations_accept_invitation_existing_membership(
|
||||
self,
|
||||
authenticated_client,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
):
|
||||
*_, tenant = tenants_fixture
|
||||
user = create_test_user
|
||||
|
||||
invitation = Invitation.objects.create(
|
||||
tenant=tenant,
|
||||
email=TEST_USER,
|
||||
inviter=user,
|
||||
expires_at=self.TOMORROW,
|
||||
)
|
||||
Membership.objects.create(user=user, tenant=tenant)
|
||||
|
||||
data = {"invitation_token": invitation.token}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="json"
|
||||
reverse("invitation-accept"),
|
||||
data=data,
|
||||
format="vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.state == Invitation.State.ACCEPTED.value
|
||||
assert (
|
||||
Membership.objects.filter(
|
||||
user__email__iexact=user.email, tenant=tenant
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
|
||||
data = {"invitation_token": "invalid_token"}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="vnd.api+json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -8578,12 +8671,10 @@ class TestInvitationViewSet:
|
||||
invitation.email = TEST_USER
|
||||
invitation.save()
|
||||
|
||||
data = {
|
||||
"invitation_token": invitation.token,
|
||||
}
|
||||
data = {"invitation_token": invitation.token}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="json"
|
||||
reverse("invitation-accept"), data=data, format="vnd.api+json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_410_GONE
|
||||
@@ -8619,12 +8710,10 @@ class TestInvitationViewSet:
|
||||
invitation.email = TEST_USER
|
||||
invitation.save()
|
||||
|
||||
data = {
|
||||
"invitation_token": invitation.token,
|
||||
}
|
||||
data = {"invitation_token": invitation.token}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="json"
|
||||
reverse("invitation-accept"), data=data, format="vnd.api+json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -8642,12 +8731,10 @@ class TestInvitationViewSet:
|
||||
invitation.email = TEST_USER
|
||||
invitation.save()
|
||||
|
||||
data = {
|
||||
"invitation_token": invitation.token,
|
||||
}
|
||||
data = {"invitation_token": invitation.token}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-accept"), data=data, format="json"
|
||||
reverse("invitation-accept"), data=data, format="vnd.api+json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -10723,9 +10810,8 @@ class TestOverviewViewSet:
|
||||
response = authenticated_client.put(reverse("overview-list"))
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
def test_overview_providers_list(
|
||||
self, authenticated_client, scan_summaries_fixture, resources_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_providers_list(self, authenticated_client, resources_fixture):
|
||||
response = authenticated_client.get(reverse("overview-providers"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 1
|
||||
@@ -10736,10 +10822,10 @@ class TestOverviewViewSet:
|
||||
# Aggregated resources include all AWS providers present in the tenant
|
||||
assert response.json()["data"][0]["attributes"]["resources"]["total"] == 3
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_providers_aggregates_same_provider_type(
|
||||
self,
|
||||
authenticated_client,
|
||||
scan_summaries_fixture,
|
||||
resources_fixture,
|
||||
providers_fixture,
|
||||
tenants_fixture,
|
||||
@@ -10790,10 +10876,10 @@ class TestOverviewViewSet:
|
||||
assert attributes["findings"]["muted"] == 7
|
||||
assert attributes["resources"]["total"] == 4
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_providers_count(
|
||||
self,
|
||||
authenticated_client,
|
||||
scan_summaries_fixture,
|
||||
resources_fixture,
|
||||
providers_fixture,
|
||||
tenants_fixture,
|
||||
@@ -11259,15 +11345,15 @@ class TestOverviewViewSet:
|
||||
assert data[0]["id"] == str(snapshot1.id)
|
||||
assert data[0]["attributes"]["overall_score"] == "55.55"
|
||||
|
||||
def test_overview_services_list_no_required_filters(
|
||||
self, authenticated_client, scan_summaries_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_services_list_no_required_filters(self, authenticated_client):
|
||||
response = authenticated_client.get(reverse("overview-services"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Should return services from latest scans
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
def test_overview_regions_list(self, authenticated_client, scan_summaries_fixture):
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_regions_list(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-regions"), {"filter[inserted_at]": TODAY}
|
||||
)
|
||||
@@ -11293,7 +11379,8 @@ class TestOverviewViewSet:
|
||||
assert regions["aws:region2"]["fail"] == 1
|
||||
assert regions["aws:region2"]["muted"] == 3
|
||||
|
||||
def test_overview_services_list(self, authenticated_client, scan_summaries_fixture):
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_services_list(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-services"), {"filter[inserted_at]": TODAY}
|
||||
)
|
||||
@@ -11941,9 +12028,8 @@ class TestOverviewViewSet:
|
||||
assert results_by_type["internet-exposed"]["total_findings"] == 10
|
||||
assert results_by_type["internet-exposed"]["failed_findings"] == 5
|
||||
|
||||
def test_overview_services_region_filter(
|
||||
self, authenticated_client, scan_summaries_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overview_services_region_filter(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-services"),
|
||||
{"filter[region]": "region1"},
|
||||
@@ -12011,7 +12097,7 @@ class TestOverviewViewSet:
|
||||
assert "gcp-service" not in service_ids
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_filter,field_to_check",
|
||||
"status_filter,_field_to_check",
|
||||
[
|
||||
("FAIL", "fail"),
|
||||
("PASS", "_pass"),
|
||||
@@ -12023,7 +12109,7 @@ class TestOverviewViewSet:
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
status_filter,
|
||||
field_to_check,
|
||||
_field_to_check,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
@@ -12663,8 +12749,9 @@ class TestOverviewViewSet:
|
||||
assert data[0]["attributes"]["new_failed_findings"] == 5
|
||||
assert data[0]["attributes"]["resources_count"] == 10
|
||||
|
||||
@pytest.mark.usefixtures("tenant_compliance_summary_fixture")
|
||||
def test_compliance_watchlist_no_filters_uses_tenant_summary(
|
||||
self, authenticated_client, tenant_compliance_summary_fixture
|
||||
self, authenticated_client
|
||||
):
|
||||
response = authenticated_client.get(reverse("overview-compliance-watchlist"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -12684,10 +12771,10 @@ class TestOverviewViewSet:
|
||||
assert by_id["gdpr_aws"]["requirements_failed"] == 0
|
||||
assert by_id["gdpr_aws"]["total_requirements"] == 7
|
||||
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_compliance_watchlist_with_provider_filter_uses_provider_scores(
|
||||
self,
|
||||
authenticated_client,
|
||||
provider_compliance_scores_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
provider1 = providers_fixture[0]
|
||||
@@ -12704,9 +12791,8 @@ class TestOverviewViewSet:
|
||||
assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
|
||||
assert by_id["aws_cis_2.0"]["total_requirements"] == 3
|
||||
|
||||
def test_compliance_watchlist_fail_dominant_logic(
|
||||
self, authenticated_client, provider_compliance_scores_fixture
|
||||
):
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_compliance_watchlist_fail_dominant_logic(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
f"{reverse('overview-compliance-watchlist')}?filter[provider_type]=aws"
|
||||
)
|
||||
@@ -12721,10 +12807,10 @@ class TestOverviewViewSet:
|
||||
assert aws_cis["requirements_manual"] == 1
|
||||
assert aws_cis["total_requirements"] == 3
|
||||
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_compliance_watchlist_provider_id_in_filter(
|
||||
self,
|
||||
authenticated_client,
|
||||
provider_compliance_scores_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
@@ -12737,10 +12823,10 @@ class TestOverviewViewSet:
|
||||
data = response.json()["data"]
|
||||
assert len(data) >= 1
|
||||
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_compliance_watchlist_provider_groups_filter(
|
||||
self,
|
||||
authenticated_client,
|
||||
provider_compliance_scores_fixture,
|
||||
providers_fixture,
|
||||
provider_groups_fixture,
|
||||
tenants_fixture,
|
||||
@@ -13640,6 +13726,26 @@ class TestSAMLTokenValidation:
|
||||
assert response2.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCustomSAMLLoginView:
|
||||
def test_dispatch_clears_stale_callback_url_when_request_has_none(self):
|
||||
request = RequestFactory().get("/api/v1/saml/login/testtenant/")
|
||||
request.session = {
|
||||
"saml_callback_url": "/invitation/accept?invitation_token=old-token"
|
||||
}
|
||||
|
||||
with patch(
|
||||
"allauth.socialaccount.providers.saml.views.LoginView.dispatch",
|
||||
return_value=JsonResponse({}),
|
||||
):
|
||||
response = CustomSAMLLoginView.as_view()(
|
||||
request, organization_slug="testtenant"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "saml_callback_url" not in request.session
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLInitiateAPIView:
|
||||
def test_valid_email_domain_and_certificates(
|
||||
@@ -13651,7 +13757,7 @@ class TestSAMLInitiateAPIView:
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": saml_setup["email"]}
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert (
|
||||
@@ -13660,11 +13766,42 @@ class TestSAMLInitiateAPIView:
|
||||
)
|
||||
assert "SAMLRequest" not in response.url
|
||||
|
||||
def test_valid_email_domain_preserves_safe_callback_url(
|
||||
self, authenticated_client, saml_setup
|
||||
):
|
||||
url = reverse("api_saml_initiate")
|
||||
callback_url = "/invitation/accept?invitation_token=test-token"
|
||||
payload = {
|
||||
"email_domain": saml_setup["email"],
|
||||
"callback_url": callback_url,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
query_params = parse_qs(urlparse(response.url).query)
|
||||
assert query_params["callback_url"] == [callback_url]
|
||||
|
||||
def test_valid_email_domain_rejects_external_callback_url(
|
||||
self, authenticated_client, saml_setup
|
||||
):
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {
|
||||
"email_domain": saml_setup["email"],
|
||||
"callback_url": "https://attacker.example/invitation",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
query_params = parse_qs(urlparse(response.url).query)
|
||||
assert "callback_url" not in query_params
|
||||
|
||||
def test_invalid_email_domain(self, authenticated_client):
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": "user@unauthorized.com"}
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json()["errors"]["detail"] == "Unauthorized domain."
|
||||
@@ -13847,7 +13984,8 @@ class TestTenantFinishACSView:
|
||||
)
|
||||
)
|
||||
request.user = user
|
||||
request.session = {}
|
||||
callback_url = "/invitation/accept?invitation_token=test-token"
|
||||
request.session = {"saml_callback_url": callback_url}
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -13889,6 +14027,7 @@ class TestTenantFinishACSView:
|
||||
assert parsed_url.netloc == expected_callback_host
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
assert "id" in query_params
|
||||
assert query_params["callbackUrl"] == [callback_url]
|
||||
|
||||
token_id = query_params["id"][0]
|
||||
token_obj = SAMLToken.objects.get(id=token_id)
|
||||
@@ -18292,10 +18431,10 @@ class TestFindingGroupViewSet:
|
||||
],
|
||||
ids=["summary_path", "finding_level_path"],
|
||||
)
|
||||
@pytest.mark.usefixtures("finding_groups_title_variants_fixture")
|
||||
def test_check_title_icontains_includes_all_title_variants(
|
||||
self,
|
||||
authenticated_client,
|
||||
finding_groups_title_variants_fixture,
|
||||
extra_filters,
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -7,9 +7,19 @@ from allauth.socialaccount.providers.oauth2.client import OAuth2Client
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import InvitationTokenExpiredException
|
||||
from api.models import Integration, Invitation, Processor, Provider, Resource
|
||||
from api.models import (
|
||||
Integration,
|
||||
Invitation,
|
||||
Membership,
|
||||
Processor,
|
||||
Provider,
|
||||
Resource,
|
||||
Role,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.v1.serializers import FindingMetadataSerializer
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db import transaction
|
||||
from django.db.models import Subquery
|
||||
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
@@ -538,6 +548,35 @@ def validate_invitation(
|
||||
return invitation
|
||||
|
||||
|
||||
def accept_invitation_for_user(
|
||||
*, user, invitation_token: str, raise_not_found: bool = False
|
||||
):
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
invitation = validate_invitation(
|
||||
invitation_token, user.email, raise_not_found=raise_not_found
|
||||
)
|
||||
with rls_transaction(str(invitation.tenant_id), using=MainRouter.admin_db):
|
||||
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
|
||||
user=user,
|
||||
tenant=invitation.tenant,
|
||||
defaults={"role": Membership.RoleChoices.MEMBER},
|
||||
)
|
||||
invitation_roles = Role.objects.using(MainRouter.admin_db).filter(
|
||||
invitations=invitation
|
||||
)
|
||||
for role in invitation_roles:
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).get_or_create(
|
||||
user=user,
|
||||
role=role,
|
||||
defaults={"tenant": invitation.tenant},
|
||||
)
|
||||
|
||||
invitation.state = Invitation.State.ACCEPTED
|
||||
invitation.save(using=MainRouter.admin_db)
|
||||
|
||||
return invitation, membership
|
||||
|
||||
|
||||
# ToRemove after removing the fallback mechanism in /findings/metadata
|
||||
def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
|
||||
filtered_ids = filtered_queryset.order_by().values("id")
|
||||
|
||||
@@ -3147,6 +3147,9 @@ class ProcessorUpdateSerializer(BaseWriteSerializer):
|
||||
|
||||
class SamlInitiateSerializer(BaseSerializerV1):
|
||||
email_domain = serializers.CharField()
|
||||
callback_url = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=2048
|
||||
)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-initiate"
|
||||
|
||||
@@ -9,7 +9,7 @@ from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
import sentry_sdk
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
@@ -129,6 +129,7 @@ from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
from api.utils import (
|
||||
CustomOAuth2Client,
|
||||
accept_invitation_for_user,
|
||||
get_findings_metadata_no_aggregations,
|
||||
initialize_prowler_integration,
|
||||
initialize_prowler_provider,
|
||||
@@ -542,6 +543,46 @@ class SchemaView(SpectacularAPIView):
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
SAML_CALLBACK_SESSION_KEY = "saml_callback_url"
|
||||
|
||||
|
||||
def _safe_callback_path(value):
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
if not value.startswith("/") or value.startswith("//"):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _get_request_invitation_token(request):
|
||||
for source_name in ("data", "POST"):
|
||||
data = getattr(request, source_name, None) or {}
|
||||
if not hasattr(data, "get"):
|
||||
continue
|
||||
invitation_token = data.get("invitation_token")
|
||||
if invitation_token:
|
||||
return invitation_token
|
||||
|
||||
wrapped_request = getattr(request, "_request", None)
|
||||
if wrapped_request and wrapped_request is not request:
|
||||
return _get_request_invitation_token(wrapped_request)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _accept_social_invitation(request, user):
|
||||
invitation_token = _get_request_invitation_token(request)
|
||||
tenant_id = getattr(request, "prowler_invitation_tenant_id", None)
|
||||
if invitation_token and not tenant_id:
|
||||
invitation, _ = accept_invitation_for_user(
|
||||
user=user,
|
||||
invitation_token=invitation_token,
|
||||
raise_not_found=True,
|
||||
)
|
||||
tenant_id = str(invitation.tenant_id)
|
||||
return tenant_id
|
||||
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
class GoogleSocialLoginView(SocialLoginView):
|
||||
adapter_class = GoogleOAuth2Adapter
|
||||
@@ -552,7 +593,11 @@ class GoogleSocialLoginView(SocialLoginView):
|
||||
original_response = super().get_response()
|
||||
|
||||
if self.user and self.user.is_authenticated:
|
||||
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
|
||||
tenant_id = _accept_social_invitation(self.request, self.user)
|
||||
serializer_data = {"email": self.user.email}
|
||||
if tenant_id:
|
||||
serializer_data["tenant_id"] = tenant_id
|
||||
serializer = TokenSocialLoginSerializer(data=serializer_data)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except TokenError as e:
|
||||
@@ -577,7 +622,11 @@ class GithubSocialLoginView(SocialLoginView):
|
||||
original_response = super().get_response()
|
||||
|
||||
if self.user and self.user.is_authenticated:
|
||||
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
|
||||
tenant_id = _accept_social_invitation(self.request, self.user)
|
||||
serializer_data = {"email": self.user.email}
|
||||
if tenant_id:
|
||||
serializer_data["tenant_id"] = tenant_id
|
||||
serializer = TokenSocialLoginSerializer(data=serializer_data)
|
||||
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -637,6 +686,10 @@ class CustomSAMLLoginView(LoginView):
|
||||
|
||||
This approach maintains security while providing better UX.
|
||||
"""
|
||||
callback_url = _safe_callback_path(request.GET.get("callback_url"))
|
||||
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
|
||||
if callback_url:
|
||||
request.session[SAML_CALLBACK_SESSION_KEY] = callback_url
|
||||
if request.method == "GET":
|
||||
# Convert GET to POST while preserving parameters
|
||||
request.method = "POST"
|
||||
@@ -681,6 +734,11 @@ class SAMLInitiateAPIView(GenericAPIView):
|
||||
"saml_login", kwargs={"organization_slug": config.email_domain}
|
||||
)
|
||||
login_url = urljoin(api_host, login_path)
|
||||
callback_url = _safe_callback_path(
|
||||
serializer.validated_data.get("callback_url")
|
||||
)
|
||||
if callback_url:
|
||||
login_url = f"{login_url}?{urlencode({'callback_url': callback_url})}"
|
||||
|
||||
return redirect(login_url)
|
||||
|
||||
@@ -896,7 +954,13 @@ class TenantFinishACSView(FinishACSView):
|
||||
token=token_data, user=user
|
||||
)
|
||||
callback_url = env.str("SAML_SSO_CALLBACK_URL")
|
||||
redirect_url = f"{callback_url}?id={saml_token.id}"
|
||||
redirect_params = {"id": str(saml_token.id)}
|
||||
saml_callback_url = _safe_callback_path(
|
||||
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
|
||||
)
|
||||
if saml_callback_url:
|
||||
redirect_params["callbackUrl"] = saml_callback_url
|
||||
redirect_url = f"{callback_url}?{urlencode(redirect_params)}"
|
||||
request.session.pop("saml_user_created", None)
|
||||
|
||||
return redirect(redirect_url)
|
||||
@@ -948,8 +1012,8 @@ class UserViewSet(BaseUserViewset):
|
||||
"""
|
||||
Returns the required permissions based on the request method.
|
||||
"""
|
||||
if self.action == "me":
|
||||
# No permissions required for me request
|
||||
if self.action in ["me", "partial_update"]:
|
||||
# No permissions required for me and partial_update requests
|
||||
self.required_permissions = []
|
||||
else:
|
||||
# Require permission for the rest of the requests
|
||||
@@ -1003,6 +1067,24 @@ class UserViewSet(BaseUserViewset):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
user = self.get_object()
|
||||
if user.id != self.request.user.id:
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
if not getattr(role, Permissions.MANAGE_USERS.value, False):
|
||||
raise ValidationError(
|
||||
"Only users with manage users permission can update other users."
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
if getattr(user, "_prefetched_objects_cache", None):
|
||||
user._prefetched_objects_cache = {}
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if kwargs["pk"] != str(self.request.user.id):
|
||||
raise ValidationError("Only the current user can be deleted.")
|
||||
@@ -4371,25 +4453,12 @@ class InvitationAcceptViewSet(BaseRLSViewSet):
|
||||
invitation_token = serializer.validated_data["invitation_token"]
|
||||
user_email = request.user.email
|
||||
|
||||
invitation = validate_invitation(
|
||||
invitation_token, user_email, raise_not_found=True
|
||||
)
|
||||
|
||||
# Proceed with accepting the invitation
|
||||
user = User.objects.using(MainRouter.admin_db).get(email=user_email)
|
||||
membership = Membership.objects.using(MainRouter.admin_db).create(
|
||||
invitation, membership = accept_invitation_for_user(
|
||||
user=user,
|
||||
tenant=invitation.tenant,
|
||||
invitation_token=invitation_token,
|
||||
raise_not_found=True,
|
||||
)
|
||||
user_role = []
|
||||
for role in invitation.roles.all():
|
||||
user_role.append(
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=role, tenant=invitation.tenant
|
||||
)
|
||||
)
|
||||
invitation.state = Invitation.State.ACCEPTED
|
||||
invitation.save(using=MainRouter.admin_db)
|
||||
|
||||
self.response_serializer_class = MembershipSerializer
|
||||
membership_serializer = self.get_serializer(membership)
|
||||
|
||||
@@ -2,8 +2,10 @@ from contextlib import nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
@@ -15,6 +17,7 @@ from api.models import (
|
||||
StatusChoices,
|
||||
Task,
|
||||
)
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django_celery_results.models import TaskResult
|
||||
from prowler.lib.check.models import Severity
|
||||
from tasks.jobs.attack_paths import findings as findings_module
|
||||
@@ -2244,6 +2247,58 @@ class TestInternetAnalysis:
|
||||
class TestAttackPathsDbUtilsGraphDataReady:
|
||||
"""Tests for db_utils functions related to graph_data_ready lifecycle."""
|
||||
|
||||
def test_database_defaults_allow_legacy_insert_without_cutover_columns(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
attack_paths_scan_id = uuid4()
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO attack_paths_scans (
|
||||
id,
|
||||
inserted_at,
|
||||
updated_at,
|
||||
state,
|
||||
progress,
|
||||
graph_data_ready,
|
||||
started_at,
|
||||
tenant_id,
|
||||
provider_id,
|
||||
scan_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
[
|
||||
attack_paths_scan_id,
|
||||
now,
|
||||
now,
|
||||
StateChoices.SCHEDULED,
|
||||
0,
|
||||
False,
|
||||
now,
|
||||
tenant.id,
|
||||
provider.id,
|
||||
scan.id,
|
||||
],
|
||||
)
|
||||
|
||||
attack_paths_scan = AttackPathsScan.objects.get(id=attack_paths_scan_id)
|
||||
|
||||
assert attack_paths_scan.is_migrated is False
|
||||
assert (
|
||||
attack_paths_scan.sink_backend == AttackPathsScan.SinkBackendChoices.NEO4J
|
||||
)
|
||||
|
||||
def test_create_attack_paths_scan_first_scan_defaults_to_false(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
|
||||
Generated
+3
-3
@@ -4674,7 +4674,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.32.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.32#4ae7c67d3fc6ccf06296517c9d05824905718ede" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.33.0"
|
||||
version = "1.33.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4862,7 +4862,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.32" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -40,6 +40,11 @@ Follow these steps to edit a user of your account:
|
||||
|
||||
<img src="/images/prowler-app/rbac/user_edit_details.png" alt="Edit User Details" width="700" />
|
||||
|
||||
<Note>
|
||||
Users can edit their own account details. Editing another user's account details requires the **Invite and Manage Users** or **admin** permission.
|
||||
|
||||
</Note>
|
||||
|
||||
#### Removing a User
|
||||
|
||||
Follow these steps to remove a user of your account:
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.32.1] (Prowler v5.32.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `KeyError: 'MANUAL'` crash while rendering the compliance summary table (e.g. CIS Microsoft 365) when a framework has manual, checks-less requirements with a Level 1/Level 2 profile; `MANUAL` findings are now skipped in the PASS/FAIL section tally instead of raising [(#11822)](https://github.com/prowler-cloud/prowler/issues/11822)
|
||||
|
||||
---
|
||||
|
||||
## [5.32.0] (Prowler v5.32.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.32.0"
|
||||
prowler_version = "5.32.1"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -395,6 +395,12 @@ def accumulate_group_status(
|
||||
) -> None:
|
||||
"""Count a finding once per group, upgrading a counted PASS to FAIL on conflict (mutates ``counts``/``seen``)."""
|
||||
previous = seen.get(index)
|
||||
if status == "MANUAL":
|
||||
# MANUAL findings come from manual, checks-less requirements and are
|
||||
# informational only: they have no PASS/FAIL/Muted column in the section
|
||||
# tally, so counting them would raise KeyError on counts[status] += 1.
|
||||
# Skip them (an unexpected status still raises loudly below).
|
||||
return
|
||||
if previous is None:
|
||||
seen[index] = status
|
||||
counts[status] += 1
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.32.0"
|
||||
version = "5.32.1"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -351,6 +351,37 @@ class Test_accumulate_group_status:
|
||||
with pytest.raises(KeyError):
|
||||
accumulate_group_status(0, "Muted", counts, {})
|
||||
|
||||
def test_manual_status_is_ignored_not_counted(self):
|
||||
# A MANUAL finding (from a manual, checks-less requirement) has no
|
||||
# PASS/FAIL/Muted column: it must be skipped, not raise KeyError, and
|
||||
# not appear in the tally. Regression test for the M365 CIS compliance
|
||||
# crash "KeyError: 'MANUAL'" (issue #11822).
|
||||
counts = {"FAIL": 0, "PASS": 0}
|
||||
seen = {}
|
||||
accumulate_group_status(0, "MANUAL", counts, seen)
|
||||
assert counts == {"FAIL": 0, "PASS": 0}
|
||||
assert seen == {}
|
||||
|
||||
def test_manual_mixed_with_pass_and_fail(self):
|
||||
# MANUAL findings interleaved with real PASS/FAIL ones only skip
|
||||
# themselves; the PASS/FAIL tally is unaffected.
|
||||
counts = {"FAIL": 0, "PASS": 0}
|
||||
seen = {}
|
||||
accumulate_group_status(0, "MANUAL", counts, seen)
|
||||
accumulate_group_status(1, "PASS", counts, seen)
|
||||
accumulate_group_status(2, "FAIL", counts, seen)
|
||||
accumulate_group_status(3, "MANUAL", counts, seen)
|
||||
assert counts == {"FAIL": 1, "PASS": 1}
|
||||
|
||||
def test_manual_ignored_on_counts_with_muted_key(self):
|
||||
# MANUAL is skipped regardless of the counts shape (e.g. the universal
|
||||
# table's PASS/FAIL/Muted buckets), never creating a "MANUAL" key.
|
||||
counts = {"FAIL": 0, "PASS": 0, "Muted": 0}
|
||||
seen = {}
|
||||
accumulate_group_status(0, "MANUAL", counts, seen)
|
||||
assert counts == {"FAIL": 0, "PASS": 0, "Muted": 0}
|
||||
assert "MANUAL" not in counts
|
||||
|
||||
|
||||
class Test_apply_config_status:
|
||||
def test_none_config_status_keeps_finding(self):
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.32.1] (Prowler v5.32.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
|
||||
|
||||
---
|
||||
|
||||
## [1.32.0] (Prowler v5.32.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -166,8 +166,13 @@ export const deleteSamlConfig = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const initiateSamlAuth = async (email: string) => {
|
||||
export const initiateSamlAuth = async (email: string, callbackUrl = "/") => {
|
||||
try {
|
||||
const attributes = {
|
||||
email_domain: email,
|
||||
...(callbackUrl !== "/" && { callback_url: callbackUrl }),
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -176,9 +181,7 @@ export const initiateSamlAuth = async (email: string) => {
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "saml-initiate",
|
||||
attributes: {
|
||||
email_domain: email,
|
||||
},
|
||||
attributes,
|
||||
},
|
||||
}),
|
||||
redirect: "manual",
|
||||
|
||||
@@ -13,6 +13,7 @@ const SignUp = async ({
|
||||
typeof resolvedSearchParams?.invitation_token === "string"
|
||||
? resolvedSearchParams.invitation_token
|
||||
: null;
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
const GOOGLE_AUTH_URL = getAuthUrl("google");
|
||||
const GITHUB_AUTH_URL = getAuthUrl("github");
|
||||
@@ -21,6 +22,7 @@ const SignUp = async ({
|
||||
<AuthForm
|
||||
type="sign-up"
|
||||
invitationToken={invitationToken}
|
||||
isCloudEnv={isCloudEnv}
|
||||
googleAuthUrl={GOOGLE_AUTH_URL}
|
||||
githubAuthUrl={GITHUB_AUTH_URL}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ColumnLatestFindings } from "@/components/overview/new-findings-table/t
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { createDict } from "@/lib/helper";
|
||||
import { createDict } from "@/lib/utils";
|
||||
import { FindingProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
@@ -45,7 +45,8 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id ?? ""];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
|
||||
@@ -594,22 +594,31 @@ const AlertFormModalContent = ({
|
||||
{errors.root && (
|
||||
<div className="text-text-error-primary text-sm">{errors.root}</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{/* mt-4 lifts the gap-4 container spacing to 32px so the distance to
|
||||
the footer matches the launch scan and triage note modals. */}
|
||||
<div className="mt-4 flex w-full justify-between gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{editingAlert && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading || saving}
|
||||
>
|
||||
{previewLoading ? "Running..." : "Test"}
|
||||
<div className="flex gap-4">
|
||||
{editingAlert && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading || saving}
|
||||
>
|
||||
{previewLoading ? "Running..." : "Test"}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" onClick={handleSubmit} disabled={saving}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
|
||||
const { access, refresh } = data.data.attributes;
|
||||
|
||||
try {
|
||||
// Invitation tokens are accepted during the social token exchange.
|
||||
const redirectPath = invitationToken ? "/" : callbackPath;
|
||||
const result = await signIn("social-oauth", {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(redirectPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(redirectPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SignIn error:", error);
|
||||
return NextResponse.redirect(
|
||||
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in Github callback:", error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
|
||||
const { access, refresh } = data.data.attributes;
|
||||
|
||||
try {
|
||||
// Invitation tokens are accepted during the social token exchange.
|
||||
const redirectPath = invitationToken ? "/" : callbackPath;
|
||||
const result = await signIn("social-oauth", {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(redirectPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(redirectPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SignIn error:", error);
|
||||
return NextResponse.redirect(
|
||||
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in Google callback:", error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
const callbackPath = getSafeCallbackPath(searchParams, "callbackUrl");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@@ -40,14 +42,14 @@ export async function GET(req: Request) {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(callbackPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(callbackPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SAML authentication failed:", error);
|
||||
return NextResponse.redirect(new URL("/sign-in", baseUrl));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SignUpForm } from "@/components/auth/oss/sign-up-form";
|
||||
export const AuthForm = ({
|
||||
type,
|
||||
invitationToken,
|
||||
isCloudEnv,
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
isGoogleOAuthEnabled,
|
||||
@@ -11,6 +12,7 @@ export const AuthForm = ({
|
||||
}: {
|
||||
type: string;
|
||||
invitationToken?: string | null;
|
||||
isCloudEnv?: boolean;
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
@@ -30,6 +32,7 @@ export const AuthForm = ({
|
||||
return (
|
||||
<SignUpForm
|
||||
invitationToken={invitationToken}
|
||||
isCloudEnv={isCloudEnv}
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { SignInFormData, signInSchema } from "@/types";
|
||||
|
||||
export const SignInForm = ({
|
||||
@@ -32,7 +33,7 @@ export const SignInForm = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||
const callbackUrl = getSafeCallbackPath(searchParams, "callbackUrl");
|
||||
|
||||
useEffect(() => {
|
||||
const samlError = searchParams.get("sso_saml_failed");
|
||||
@@ -102,7 +103,7 @@ export const SignInForm = ({
|
||||
form.setValue("password", "");
|
||||
}
|
||||
|
||||
const result = await initiateSamlAuth(email);
|
||||
const result = await initiateSamlAuth(email, callbackUrl);
|
||||
|
||||
if (result.success && result.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
@@ -181,6 +182,7 @@ export const SignInForm = ({
|
||||
<SocialButtons
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
callbackUrl={callbackUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
isGithubOAuthEnabled={isGithubOAuthEnabled}
|
||||
/>
|
||||
|
||||
@@ -41,12 +41,14 @@ const FORM_ERROR_TYPE = {
|
||||
|
||||
export const SignUpForm = ({
|
||||
invitationToken,
|
||||
isCloudEnv,
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
isGoogleOAuthEnabled,
|
||||
isGithubOAuthEnabled,
|
||||
}: {
|
||||
invitationToken?: string | null;
|
||||
isCloudEnv?: boolean;
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
@@ -54,6 +56,9 @@ export const SignUpForm = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = invitationToken
|
||||
? `/invitation/accept?invitation_token=${encodeURIComponent(invitationToken)}`
|
||||
: "/";
|
||||
|
||||
const form = useForm<SignUpFormData>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
@@ -75,8 +80,14 @@ export const SignUpForm = ({
|
||||
name: "password",
|
||||
defaultValue: "",
|
||||
});
|
||||
const termsAccepted = useWatch({
|
||||
control: form.control,
|
||||
name: "termsAndConditions",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
const isSocialAuthDisabled = Boolean(isCloudEnv && !termsAccepted);
|
||||
|
||||
const onSubmit = async (data: SignUpFormData) => {
|
||||
const newUser = await createNewUser(data);
|
||||
@@ -88,7 +99,7 @@ export const SignUpForm = ({
|
||||
});
|
||||
form.reset();
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloudEnv) {
|
||||
router.push("/email-verification");
|
||||
} else {
|
||||
router.push("/sign-in");
|
||||
@@ -200,7 +211,7 @@ export const SignUpForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
|
||||
{isCloudEnv && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="termsAndConditions"
|
||||
@@ -243,15 +254,18 @@ export const SignUpForm = ({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{!invitationToken && (
|
||||
{(!invitationToken || isCloudEnv) && (
|
||||
<>
|
||||
<AuthDivider />
|
||||
<div className="flex flex-col gap-2">
|
||||
<SocialButtons
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
callbackUrl={callbackUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
isGithubOAuthEnabled={isGithubOAuthEnabled}
|
||||
isDisabled={isSocialAuthDisabled}
|
||||
disabledTooltipContent="Accept the Terms of Service to continue."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,83 +1,153 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { appendCallbackState } from "@/lib/auth-callback-url";
|
||||
|
||||
type SocialProvider = {
|
||||
key: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
isOAuthEnabled?: boolean;
|
||||
enabledIcon: string;
|
||||
disabledIcon: string;
|
||||
disabledDocs: {
|
||||
message: string;
|
||||
href: string;
|
||||
};
|
||||
};
|
||||
|
||||
const SocialButton = ({
|
||||
provider,
|
||||
isDisabled,
|
||||
disabledTooltipContent,
|
||||
}: {
|
||||
provider: SocialProvider;
|
||||
isDisabled: boolean;
|
||||
disabledTooltipContent: ReactNode;
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={!isDisabled}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={
|
||||
provider.isOAuthEnabled
|
||||
? provider.enabledIcon
|
||||
: provider.disabledIcon
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
{provider.label}
|
||||
</span>
|
||||
) : (
|
||||
<a href={provider.url} className="flex items-center gap-2">
|
||||
<Icon icon={provider.enabledIcon} width={24} />
|
||||
{provider.label}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!isDisabled) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex w-full">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="w-96">
|
||||
{provider.isOAuthEnabled ? (
|
||||
disabledTooltipContent
|
||||
) : (
|
||||
<div className="flex-inline text-small">
|
||||
{provider.disabledDocs.message}{" "}
|
||||
<CustomLink href={provider.disabledDocs.href}>
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialButtons = ({
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
callbackUrl = "/",
|
||||
isGoogleOAuthEnabled,
|
||||
isGithubOAuthEnabled,
|
||||
isDisabled = false,
|
||||
disabledTooltipContent,
|
||||
}: {
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
callbackUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
isGithubOAuthEnabled?: boolean;
|
||||
}) => (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex-inline text-small">
|
||||
Social Login with Google is not enabled.{" "}
|
||||
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration">
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
shadow="sm"
|
||||
isDisabled={isGoogleOAuthEnabled}
|
||||
className="w-96"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={isGoogleOAuthEnabled}
|
||||
disabled={!isGoogleOAuthEnabled}
|
||||
>
|
||||
<a href={googleAuthUrl} className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={
|
||||
isGoogleOAuthEnabled
|
||||
? "flat-color-icons:google"
|
||||
: "simple-icons:google"
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
Continue with Google
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex-inline text-small">
|
||||
Social Login with Github is not enabled.{" "}
|
||||
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration">
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
shadow="sm"
|
||||
isDisabled={isGithubOAuthEnabled}
|
||||
className="w-96"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={isGithubOAuthEnabled}
|
||||
disabled={!isGithubOAuthEnabled}
|
||||
>
|
||||
<a href={githubAuthUrl} className="flex items-center gap-2">
|
||||
<Icon icon="simple-icons:github" width={24} />
|
||||
Continue with Github
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
isDisabled?: boolean;
|
||||
disabledTooltipContent?: ReactNode;
|
||||
}) => {
|
||||
const googleUrl = googleAuthUrl
|
||||
? appendCallbackState(googleAuthUrl, callbackUrl)
|
||||
: undefined;
|
||||
const githubUrl = githubAuthUrl
|
||||
? appendCallbackState(githubAuthUrl, callbackUrl)
|
||||
: undefined;
|
||||
const socialDisabledTooltip =
|
||||
disabledTooltipContent || "Social login is currently unavailable.";
|
||||
|
||||
const providers: SocialProvider[] = [
|
||||
{
|
||||
key: "google",
|
||||
label: "Continue with Google",
|
||||
url: googleUrl,
|
||||
isOAuthEnabled: isGoogleOAuthEnabled,
|
||||
enabledIcon: "flat-color-icons:google",
|
||||
disabledIcon: "simple-icons:google",
|
||||
disabledDocs: {
|
||||
message: "Social Login with Google is not enabled.",
|
||||
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
label: "Continue with Github",
|
||||
url: githubUrl,
|
||||
isOAuthEnabled: isGithubOAuthEnabled,
|
||||
enabledIcon: "simple-icons:github",
|
||||
disabledIcon: "simple-icons:github",
|
||||
disabledDocs: {
|
||||
message: "Social Login with Github is not enabled.",
|
||||
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map((provider) => (
|
||||
<SocialButton
|
||||
key={provider.key}
|
||||
provider={provider}
|
||||
isDisabled={isDisabled || !provider.isOAuthEnabled || !provider.url}
|
||||
disabledTooltipContent={socialDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,4 +13,30 @@ describe("client accordion content", () => {
|
||||
expect(source).toContain("getStandaloneFindingColumns");
|
||||
expect(source).not.toContain("getColumnFindings");
|
||||
});
|
||||
|
||||
it("wires triage update and note loading actions into compliance findings", () => {
|
||||
expect(source).toContain("updateFindingTriage");
|
||||
expect(source).toContain("loadLatestFindingTriageNote");
|
||||
expect(source).toContain("onTriageUpdateAction");
|
||||
expect(source).toContain("onTriageNoteLoadAction");
|
||||
});
|
||||
|
||||
it("refetches findings after mutelist-shortcut triage updates like the resource drawer", () => {
|
||||
expect(source).toContain("shouldRefreshAfterTriageUpdate");
|
||||
expect(source).toContain("reload()");
|
||||
});
|
||||
|
||||
it("delegates data fetching to the hook instead of effect/ref choreography", () => {
|
||||
expect(source).toContain("useRequirementFindings");
|
||||
expect(source).not.toContain("useEffect");
|
||||
expect(source).not.toContain("useRef");
|
||||
});
|
||||
|
||||
it("gates the skeleton on the hook loading state and surfaces fetch errors", () => {
|
||||
// A disabled fetch (e.g. "No findings" status) must not skeleton forever,
|
||||
// and a failed fetch must offer a retry instead of hanging.
|
||||
expect(source).toContain("isLoading && requirement.status");
|
||||
expect(source).not.toContain("findings === null");
|
||||
expect(source).toContain("Try again");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,26 @@
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import { Alert, AlertDescription, Button } from "@/components/shadcn";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { useRequirementFindings } from "./use-requirement-findings";
|
||||
|
||||
interface ClientAccordionContentProps {
|
||||
requirement: Requirement;
|
||||
@@ -31,100 +36,53 @@ export const ClientAccordionContent = ({
|
||||
scanId,
|
||||
disableFindings = false,
|
||||
}: ClientAccordionContentProps) => {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const searchParams = useSearchParams();
|
||||
const pageNumber = searchParams.get("page") || "1";
|
||||
const pageSize = searchParams.get("pageSize") || "10";
|
||||
const complianceId = searchParams.get("complianceId");
|
||||
const openFindingId = searchParams.get("id");
|
||||
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
|
||||
const loadedPageRef = useRef<string | null>(null);
|
||||
const loadedPageSizeRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const loadedMutedRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
// Respect the user's muted preference from the URL; default to EXCLUDE
|
||||
// so the requirement view stays consistent with every other findings
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
if (
|
||||
!disableFindings &&
|
||||
requirement.check_ids?.length > 0 &&
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedPageSizeRef.current !== pageSize ||
|
||||
loadedSortRef.current !== sort ||
|
||||
loadedMutedRef.current !== mutedFilter ||
|
||||
!isExpandedRef.current)
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
const checks = requirement.check_ids || [];
|
||||
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: encodedSort,
|
||||
});
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
// Create dictionaries for resources, scans, and providers
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
// Expand each finding with its corresponding resource, scan, and provider
|
||||
const expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [
|
||||
requirement,
|
||||
const {
|
||||
findings,
|
||||
expandedFindings,
|
||||
isLoading,
|
||||
error,
|
||||
patchTriageUpdate,
|
||||
reload,
|
||||
} = useRequirementFindings({
|
||||
enabled:
|
||||
!disableFindings &&
|
||||
checks.length > 0 &&
|
||||
requirement.status !== "No findings",
|
||||
checkIds: checks,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
disableFindings,
|
||||
]);
|
||||
});
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
|
||||
// Mutelist-shortcut statuses mute the finding server-side; refetch so the
|
||||
// list honors the muted filter, matching the resource drawer behavior.
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
|
||||
patchTriageUpdate(input);
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
@@ -148,7 +106,6 @@ export const ClientAccordionContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
const checks = requirement.check_ids || [];
|
||||
const checksList = (
|
||||
<div className="flex items-center px-2 text-sm">
|
||||
<div className="w-full flex-col">
|
||||
@@ -174,7 +131,26 @@ export const ClientAccordionContent = ({
|
||||
];
|
||||
|
||||
const renderFindingsTable = () => {
|
||||
if (findings === null && requirement.status !== "MANUAL") {
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="error" className="mt-3">
|
||||
<AlertTriangle />
|
||||
<AlertDescription className="flex flex-wrap items-center gap-2">
|
||||
<span>{error}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="h-auto p-0"
|
||||
onClick={reload}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && requirement.status !== "MANUAL") {
|
||||
return <SkeletonTableFindings />;
|
||||
}
|
||||
|
||||
@@ -184,8 +160,12 @@ export const ClientAccordionContent = ({
|
||||
<h4 className="mb-2 text-sm font-medium">Findings</h4>
|
||||
|
||||
<DataTable
|
||||
columns={getStandaloneFindingColumns({ openFindingId })}
|
||||
data={expandedFindings || []}
|
||||
columns={getStandaloneFindingColumns({
|
||||
openFindingId,
|
||||
onTriageUpdateAction: handleTriageUpdate,
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
})}
|
||||
data={expandedFindings}
|
||||
metadata={findings?.meta}
|
||||
disableScroll={true}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { useRequirementFindings } from "./use-requirement-findings";
|
||||
|
||||
const findingsActionsMock = vi.hoisted(() => ({
|
||||
getFindings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => findingsActionsMock);
|
||||
|
||||
function makeFindingsResponse() {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: { muted: false, status: "FAIL" },
|
||||
triage: {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
},
|
||||
relationships: {
|
||||
scan: { data: { id: "scan-1" } },
|
||||
resources: { data: [{ id: "resource-1" }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
included: [
|
||||
{
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
relationships: { provider: { data: { id: "provider-1" } } },
|
||||
},
|
||||
{ type: "resources", id: "resource-1" },
|
||||
{ type: "providers", id: "provider-1" },
|
||||
],
|
||||
meta: { pagination: { count: 1, pages: 1 } },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOptions(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
enabled: true,
|
||||
checkIds: ["check_1", "check_2"],
|
||||
scanId: "scan-1",
|
||||
pageNumber: "1",
|
||||
pageSize: "10",
|
||||
sort: "+severity",
|
||||
region: "",
|
||||
mutedFilter: "false",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("useRequirementFindings", () => {
|
||||
beforeEach(() => {
|
||||
findingsActionsMock.getFindings.mockReset();
|
||||
findingsActionsMock.getFindings.mockResolvedValue(makeFindingsResponse());
|
||||
});
|
||||
|
||||
it("should fetch findings with the requirement filters and strip the sort plus sign", async () => {
|
||||
// Given / When
|
||||
renderHook(() => useRequirementFindings(defaultOptions()));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith({
|
||||
filters: {
|
||||
"filter[check_id__in]": "check_1,check_2",
|
||||
"filter[scan]": "scan-1",
|
||||
"filter[muted]": "false",
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sort: "severity",
|
||||
});
|
||||
});
|
||||
|
||||
it("should expand findings with their included scan, resource, and provider", async () => {
|
||||
// Given / When
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
const [expanded] = result.current.expandedFindings;
|
||||
expect(expanded.relationships).toEqual({
|
||||
scan: expect.objectContaining({ id: "scan-1" }),
|
||||
resource: expect.objectContaining({ id: "resource-1" }),
|
||||
provider: expect.objectContaining({ id: "provider-1" }),
|
||||
});
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should not fetch when disabled or without check ids", async () => {
|
||||
// Given / When
|
||||
renderHook(() =>
|
||||
useRequirementFindings(defaultOptions({ enabled: false })),
|
||||
);
|
||||
renderHook(() => useRequirementFindings(defaultOptions({ checkIds: [] })));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not report loading when the fetch is disabled", async () => {
|
||||
// Given / When
|
||||
const disabled = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions({ enabled: false })),
|
||||
);
|
||||
const withoutChecks = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions({ checkIds: [] })),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then — a skipped fetch must not look like a pending one.
|
||||
expect(disabled.result.current.isLoading).toBe(false);
|
||||
expect(withoutChecks.result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("should report loading until the fetch settles", async () => {
|
||||
// Given
|
||||
let resolveFetch: (value: unknown) => void = () => {};
|
||||
findingsActionsMock.getFindings.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFetch = resolve)),
|
||||
);
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
resolveFetch(makeFindingsResponse());
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("should expose an error and stop loading when the fetch fails", async () => {
|
||||
// Given
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
findingsActionsMock.getFindings.mockRejectedValue(
|
||||
new Error("network down"),
|
||||
);
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then — the caller can render an error state instead of a skeleton.
|
||||
expect(result.current.error).toBe("Could not load findings.");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.findings).toBeNull();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should clear the error and recover on reload", async () => {
|
||||
// Given: first fetch fails, retry succeeds
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
findingsActionsMock.getFindings
|
||||
.mockRejectedValueOnce(new Error("network down"))
|
||||
.mockResolvedValueOnce(makeFindingsResponse());
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
expect(result.current.error).toBe("Could not load findings.");
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.reload();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.findings).not.toBeNull();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not refetch when only the checkIds array identity changes", async () => {
|
||||
// Given
|
||||
const { rerender } = renderHook((props) => useRequirementFindings(props), {
|
||||
initialProps: defaultOptions(),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// When: same values, fresh array identity (parent re-render)
|
||||
rerender(defaultOptions());
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should refetch when a query parameter changes", async () => {
|
||||
// Given
|
||||
const { rerender } = renderHook((props) => useRequirementFindings(props), {
|
||||
initialProps: defaultOptions(),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
rerender(defaultOptions({ pageNumber: "2" }));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should refetch on reload keeping previous data visible meanwhile", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.reload();
|
||||
});
|
||||
|
||||
// Then: previous data is not cleared while the refetch is in flight
|
||||
expect(result.current.findings).not.toBeNull();
|
||||
await flushAsync();
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should patch the matching row triage optimistically", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.patchTriageUpdate({
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.expandedFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore stale responses after the query changes", async () => {
|
||||
// Given: first request resolves late, second resolves immediately
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
const staleResponse = {
|
||||
...makeFindingsResponse(),
|
||||
meta: { pagination: { count: 99, pages: 9 } },
|
||||
};
|
||||
findingsActionsMock.getFindings
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve)),
|
||||
)
|
||||
.mockResolvedValueOnce(makeFindingsResponse());
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props) => useRequirementFindings(props),
|
||||
{ initialProps: defaultOptions() },
|
||||
);
|
||||
|
||||
// When: the query changes before the first request settles
|
||||
rerender(defaultOptions({ pageNumber: "2" }));
|
||||
await flushAsync();
|
||||
act(() => {
|
||||
resolveFirst(staleResponse);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then: the stale response never overwrites the fresh one
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings";
|
||||
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
|
||||
import { createDict } from "@/lib/utils";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
interface UseRequirementFindingsOptions {
|
||||
enabled: boolean;
|
||||
checkIds: string[];
|
||||
scanId: string;
|
||||
pageNumber: string;
|
||||
pageSize: string;
|
||||
sort: string;
|
||||
region: string;
|
||||
mutedFilter: string;
|
||||
}
|
||||
|
||||
interface UseRequirementFindingsReturn {
|
||||
findings: FindingsResponse | null;
|
||||
expandedFindings: FindingProps[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const FINDINGS_LOAD_ERROR = "Could not load findings.";
|
||||
|
||||
export function useRequirementFindings({
|
||||
enabled,
|
||||
checkIds,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
}: UseRequirementFindingsOptions): UseRequirementFindingsReturn {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
// Depend on the joined value, not the array: the requirement prop gets a
|
||||
// fresh identity on every parent render and must not retrigger the fetch.
|
||||
const checkIdsKey = checkIds.join(",");
|
||||
const isFetchEnabled = enabled && checkIdsKey.length > 0;
|
||||
// A skipped fetch is not a pending one; without this the caller would show
|
||||
// a skeleton forever for requirements whose fetch never runs.
|
||||
const isLoading = isFetchEnabled && findings === null && error === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadFindings = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIdsKey,
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: sort.replace(/^\+/, ""),
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
const expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id ?? ""];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Error loading findings:", error);
|
||||
setError(FINDINGS_LOAD_ERROR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadFindings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isFetchEnabled,
|
||||
checkIdsKey,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
reloadNonce,
|
||||
]);
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
setExpandedFindings((currentFindings) =>
|
||||
applyOptimisticFindingTriageRowsUpdate(currentFindings, input),
|
||||
);
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
setReloadNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
findings,
|
||||
expandedFindings,
|
||||
isLoading,
|
||||
error,
|
||||
patchTriageUpdate,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,7 @@ beforeAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
@@ -147,6 +148,21 @@ describe("FindingNoteModal", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should render a documentation link without requiring Remediating status", () => {
|
||||
// Given / When
|
||||
renderNoteModal();
|
||||
|
||||
// Then
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /triage documentation/i,
|
||||
});
|
||||
expect(docsLink).toHaveAttribute("href", DOCS_URLS.FINDINGS_TRIAGE);
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
expect(
|
||||
screen.queryByText(/automatically changed to Resolved/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -157,7 +173,7 @@ describe("FindingNoteModal", () => {
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Documented owner follow-up.");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
@@ -189,7 +205,7 @@ describe("FindingNoteModal", () => {
|
||||
// When
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.type(textarea, " Initial triage note. ");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
@@ -214,7 +230,7 @@ describe("FindingNoteModal", () => {
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
@@ -239,7 +255,7 @@ describe("FindingNoteModal", () => {
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.type(screen.getByLabelText("Note text"), "Changed note");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
@@ -278,7 +294,7 @@ describe("FindingNoteModal", () => {
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Documenting the resolution.");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith(
|
||||
@@ -306,9 +322,7 @@ describe("FindingNoteModal", () => {
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save changes" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
|
||||
@@ -325,7 +339,7 @@ describe("FindingNoteModal", () => {
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveAttribute("data-disabled", "");
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
@@ -360,7 +374,7 @@ describe("FindingNoteModal", () => {
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
@@ -131,10 +131,25 @@ export function FindingNoteModal({
|
||||
unbreakable content (e.g. resource UIDs) widens the grid track past
|
||||
the modal instead of truncating. */}
|
||||
<form className="flex min-w-0 flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<span>Learn how triage states work in the</span>
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_TRIAGE}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Triage documentation</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-border-input-primary flex items-center gap-4 rounded-lg border p-3">
|
||||
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{findingContext.providerType ? (
|
||||
<ProviderTypeIcon type={findingContext.providerType} size={22} />
|
||||
<ProviderTypeIcon type={findingContext.providerType} size={36} />
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary text-xs font-semibold">
|
||||
{findingContext.provider?.slice(0, 3).toUpperCase() ?? "—"}
|
||||
@@ -192,12 +207,7 @@ export function FindingNoteModal({
|
||||
|
||||
{shouldShowRemediatingInfo && (
|
||||
<Alert variant="info">
|
||||
<AlertDescription>
|
||||
{REMEDIATING_INFO_COPY}.{" "}
|
||||
<CustomLink href={DOCS_URLS.FINDINGS_TRIAGE} size="sm">
|
||||
Learn more
|
||||
</CustomLink>
|
||||
</AlertDescription>
|
||||
<AlertDescription>{REMEDIATING_INFO_COPY}.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -225,10 +235,12 @@ export function FindingNoteModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
{/* mt-3 lifts the gap-5 form spacing to 32px so the distance to the
|
||||
footer matches the launch scan and alert modals. */}
|
||||
<div className="mt-3 flex w-full justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
@@ -248,7 +260,7 @@ export function FindingNoteModal({
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: canSubmit || isCloudOnly
|
||||
? "Save changes"
|
||||
? "Save"
|
||||
: "Unavailable"}
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
@@ -373,7 +373,7 @@ describe("finding triage cells", () => {
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getResourceDrawerData } from "@/actions/resources";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -57,26 +53,7 @@ export function useResourceDrawerBootstrap({
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
setFindingsData((findings) =>
|
||||
findings.map((finding) => {
|
||||
if (!finding.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
attributes: {
|
||||
...finding.attributes,
|
||||
muted: shouldMarkMuted ? true : finding.attributes.muted,
|
||||
muted_reason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.attributes.muted_reason,
|
||||
},
|
||||
};
|
||||
}),
|
||||
applyOptimisticFindingTriageRowsUpdate(findings, input),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendCallbackState,
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
|
||||
describe("auth callback URL helpers", () => {
|
||||
describe("when appending OAuth state", () => {
|
||||
it("should add a relative callback path as provider state", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
const callbackPath = "/invitation/accept?invitation_token=test-token";
|
||||
|
||||
const result = appendCallbackState(authUrl, callbackPath);
|
||||
|
||||
expect(new URL(result).searchParams.get("state")).toBe(callbackPath);
|
||||
});
|
||||
|
||||
it("should not add state for the default callback path", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
|
||||
const result = appendCallbackState(authUrl, "/");
|
||||
|
||||
expect(new URL(result).searchParams.has("state")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when reading callback paths", () => {
|
||||
it("should return relative callback paths", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "/invitation/accept?invitation_token=test-token",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/invitation/accept?invitation_token=test-token");
|
||||
});
|
||||
|
||||
it("should reject external callback URLs", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "https://attacker.example/phishing",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject protocol-relative callback URLs", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "//attacker.example/phishing",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject backslash-normalized callback URLs", () => {
|
||||
const params = new URLSearchParams({ state: "/\\attacker.example" });
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject callback URLs with control characters before the host", () => {
|
||||
const params = new URLSearchParams({ state: "/\t/attacker.example" });
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should preserve the query string of relative callback paths", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "/invitation/accept?invitation_token=test-token&foo=bar",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe(
|
||||
"/invitation/accept?invitation_token=test-token&foo=bar",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when appending OAuth state for unsafe paths", () => {
|
||||
it("should not add a backslash-normalized path as provider state", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
|
||||
const result = appendCallbackState(authUrl, "/\\attacker.example");
|
||||
|
||||
expect(new URL(result).searchParams.has("state")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when reading invitation tokens", () => {
|
||||
it("should return invitation tokens from safe callback paths", () => {
|
||||
const callbackPath = "/invitation/accept?invitation_token=test-token";
|
||||
|
||||
const result = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
expect(result).toBe("test-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const DEFAULT_CALLBACK_PATH = "/";
|
||||
const INVITATION_TOKEN_PARAM = "invitation_token";
|
||||
// Origin used only to resolve relative paths; never part of the returned value.
|
||||
const INTERNAL_ORIGIN = "http://localhost";
|
||||
|
||||
type CallbackSearchParams = {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
export const getSafeCallbackPathFromValue = (
|
||||
value: string | null | undefined,
|
||||
) => {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
// A prefix check is not enough: the URL parser normalizes backslashes and
|
||||
// control characters, so "/\evil.com" or "/\t/evil.com" pass the check above
|
||||
// yet resolve to an external origin. Resolve against a fixed origin and
|
||||
// confirm it stayed internal before trusting the path.
|
||||
try {
|
||||
const url = new URL(value, INTERNAL_ORIGIN);
|
||||
if (url.origin !== INTERNAL_ORIGIN) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch (_error) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSafeCallbackPath = (
|
||||
searchParams: CallbackSearchParams,
|
||||
key = "state",
|
||||
) => getSafeCallbackPathFromValue(searchParams.get(key));
|
||||
|
||||
export const appendCallbackState = (authUrl: string, callbackPath: string) => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(authUrl);
|
||||
url.searchParams.set("state", safeCallbackPath);
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return authUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const getInvitationTokenFromCallbackPath = (callbackPath: string) => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(safeCallbackPath, "http://localhost");
|
||||
return url.searchParams.get(INVITATION_TOKEN_PARAM);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -23,12 +23,12 @@ export function expandFindingWithRelationships(
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id ?? ""];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { ...finding.relationships, scan, resource, provider },
|
||||
} as FindingProps;
|
||||
} as unknown as FindingProps;
|
||||
}
|
||||
|
||||
export function findingToFindingResourceRow(
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
applyOptimisticFindingTriageRowsUpdate,
|
||||
applyOptimisticFindingTriageRowUpdate,
|
||||
} from "./finding-triage";
|
||||
|
||||
interface TestFindingRowAttributes {
|
||||
muted: boolean;
|
||||
muted_reason?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TestFindingRow {
|
||||
id: string;
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: TestFindingRowAttributes;
|
||||
}
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFindingRow(overrides?: Partial<TestFindingRow>): TestFindingRow {
|
||||
return {
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
attributes: {
|
||||
muted: false,
|
||||
muted_reason: undefined,
|
||||
status: "FAIL",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finding triage optimistic row updates", () => {
|
||||
it("should patch matching finding row triage and muted attributes", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow();
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
note: "Accepted by owner.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).not.toBe(finding);
|
||||
expect(result.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
isMuted: true,
|
||||
}),
|
||||
);
|
||||
expect(result.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
muted: true,
|
||||
muted_reason: "Finding triage status changed to Risk Accepted.",
|
||||
status: "FAIL",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave non-matching rows unchanged when patching a list", () => {
|
||||
// Given
|
||||
const matchingFinding = makeFindingRow();
|
||||
const otherFinding = makeFindingRow({
|
||||
id: "finding-2",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
triageId: "triage-2",
|
||||
}),
|
||||
});
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowsUpdate(
|
||||
[matchingFinding, otherFinding],
|
||||
{
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toBe(otherFinding);
|
||||
});
|
||||
|
||||
it("should preserve muted attributes when leaving a mutelist-shortcut status", () => {
|
||||
// Given: a finding muted by a previous shortcut transition. The server
|
||||
// never removes the mute rule when the status moves on, so the optimistic
|
||||
// update must not unmute the row either.
|
||||
const finding = makeFindingRow({
|
||||
triage: makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
isMuted: true,
|
||||
}),
|
||||
attributes: {
|
||||
muted: true,
|
||||
muted_reason: "Finding triage status changed to Risk Accepted.",
|
||||
status: "FAIL",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
isMuted: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
isMuted: true,
|
||||
}),
|
||||
);
|
||||
expect(result.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
muted: true,
|
||||
muted_reason: "Finding triage status changed to Risk Accepted.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not overwrite muted_reason when an already muted finding enters a shortcut status", () => {
|
||||
// Given: muted through some other channel (e.g. a mutelist rule).
|
||||
const finding = makeFindingRow({
|
||||
triage: makeTriageSummary({ isMuted: true }),
|
||||
attributes: {
|
||||
muted: true,
|
||||
muted_reason: "Muted by mutelist rule.",
|
||||
status: "FAIL",
|
||||
},
|
||||
});
|
||||
|
||||
// When: no new mute rule is created for already muted findings, so the
|
||||
// optimistic reason must keep the original one.
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
muted: true,
|
||||
muted_reason: "Muted by mutelist rule.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave rows without triage unchanged", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow({ triage: undefined });
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
note: "No triage payload on this row.",
|
||||
isMuted: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBe(finding);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,16 @@ import {
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
interface FindingTriageRowAttributes {
|
||||
muted?: boolean;
|
||||
muted_reason?: string;
|
||||
}
|
||||
|
||||
export interface FindingTriageRow {
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: FindingTriageRowAttributes;
|
||||
}
|
||||
|
||||
export const shouldMarkFindingMutedForTriageUpdate = (
|
||||
input: UpdateFindingTriageInput,
|
||||
): boolean => Boolean(input.status && isMutelistShortcutStatus(input.status));
|
||||
@@ -45,3 +55,39 @@ export const applyOptimisticTriageSummaryUpdate = (
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const applyOptimisticFindingTriageRowUpdate = <
|
||||
TRow extends FindingTriageRow,
|
||||
>(
|
||||
finding: TRow,
|
||||
input: UpdateFindingTriageInput,
|
||||
): TRow => {
|
||||
if (!finding.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
attributes: {
|
||||
...finding.attributes,
|
||||
muted: shouldMarkMuted ? true : finding.attributes.muted,
|
||||
muted_reason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.attributes.muted_reason,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const applyOptimisticFindingTriageRowsUpdate = <
|
||||
TRow extends FindingTriageRow,
|
||||
>(
|
||||
findings: TRow[],
|
||||
input: UpdateFindingTriageInput,
|
||||
): TRow[] =>
|
||||
findings.map((finding) =>
|
||||
applyOptimisticFindingTriageRowUpdate(finding, input),
|
||||
);
|
||||
|
||||
@@ -383,21 +383,6 @@ export const checkTaskStatus = async (
|
||||
export const wait = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper function to create dictionaries by type
|
||||
export function createDict(type: string, data: any) {
|
||||
const includedField = data?.included?.filter(
|
||||
(item: { type: string }) => item.type === type,
|
||||
);
|
||||
|
||||
if (!includedField || includedField.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
includedField.map((item: { id: string }) => [item.id, item]),
|
||||
);
|
||||
}
|
||||
|
||||
export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export const convertFileToUrl = (file: File) => URL.createObjectURL(file);
|
||||
|
||||
@@ -28,3 +28,39 @@ export function getOptionalText(value: unknown): string | undefined {
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
interface IncludedApiItemRelationshipRef {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface IncludedApiItemRelationship {
|
||||
data?: IncludedApiItemRelationshipRef;
|
||||
}
|
||||
|
||||
interface IncludedApiItem {
|
||||
id: string;
|
||||
type: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
relationships?: Record<string, IncludedApiItemRelationship>;
|
||||
}
|
||||
|
||||
// Indexes a JSON:API `included` array by id for the requested resource type.
|
||||
export function createDict<T extends IncludedApiItem = IncludedApiItem>(
|
||||
type: string,
|
||||
data: { included?: unknown[] } | null | undefined,
|
||||
): Record<string, T> {
|
||||
const includedField = data?.included?.filter(
|
||||
(item): item is T =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item as IncludedApiItem).type === type,
|
||||
);
|
||||
|
||||
if (!includedField || includedField.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
includedField.map((item): [string, T] => [item.id, item]),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user