Compare commits

...
47 changed files with 1564 additions and 151 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -0,0 +1 @@
OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
+1 -1
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.36.0"
version = "1.37.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.36.0
version: 1.37.0
description: |-
Prowler API specification.
+64 -1
View File
@@ -3,7 +3,12 @@ from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
)
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
from api.v1.serializer_utils.providers import ProviderSecretField
from api.v1.serializers import (
ImageProviderSecret,
KubernetesProviderSecret,
OracleCloudProviderSecret,
)
from rest_framework.exceptions import ValidationError
@@ -190,6 +195,64 @@ class TestImageProviderSecret:
assert "non_field_errors" in serializer.errors
class TestOracleCloudProviderSecret:
def valid_secret(self, **overrides):
secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
}
secret.update(overrides)
return secret
def test_accepts_regionless_secret(self):
serializer = OracleCloudProviderSecret(data=self.valid_secret())
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
)
assert serializer.is_valid(), serializer.errors
assert legacy_field not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_includes_legacy_region_field(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
for credential_schema in schema["oneOf"]
if credential_schema["title"]
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
class TestKubernetesProviderSecret:
def test_valid_static_kubeconfig_is_accepted(self):
kubeconfig_content = """
+86 -3
View File
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
key="value", mutelist_content={"key": "value"}
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_removes_region_string(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
}
mock_return_prowler_provider.return_value = MagicMock()
initialize_prowler_provider(provider)
mock_return_prowler_provider.return_value.assert_called_once_with(
user="ocid1.user.oc1..fake",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
}
mock_return_prowler_provider.return_value = MagicMock()
initialize_prowler_provider(provider)
mock_return_prowler_provider.return_value.assert_called_once_with(
user="ocid1.user.oc1..fake",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
)
class TestProwlerProviderConnectionTest:
@patch("api.utils.return_prowler_provider")
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
key="value", provider_id="1234567890", raise_on_exception=False
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
self,
):
secret_dict = {
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
result = get_prowler_provider_kwargs(provider)
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
assert result == expected_result
assert result == {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
provider_uid = "provider_uid"
+139 -3
View File
@@ -2917,6 +2917,48 @@ class TestProviderGroupViewSet:
@pytest.mark.django_db
class TestProviderSecretViewSet:
@staticmethod
def _oraclecloud_secret(**overrides):
secret = {
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "test-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
}
secret.update(overrides)
return secret
def _create_oraclecloud_secret(
self,
authenticated_client,
oraclecloud_provider,
secret,
name="OCI Secret",
):
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": name,
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": secret,
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(oraclecloud_provider.id),
}
}
},
}
}
return authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
response = authenticated_client.get(reverse("providersecret-list"))
assert response.status_code == status.HTTP_200_OK
@@ -3076,7 +3118,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with key_file)
@@ -3088,7 +3129,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_file": "/path/to/oci_api_key.pem",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with passphrase)
@@ -3100,7 +3140,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
"pass_phrase": "my-secure-passphrase",
},
),
@@ -3258,6 +3297,103 @@ current-context: test-context
== data["data"]["relationships"]["provider"]["data"]["id"]
)
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
self,
authenticated_client,
oraclecloud_provider,
):
response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert "region" not in provider_secret.secret
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
self,
authenticated_client,
oraclecloud_provider,
):
response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(
key_content=" test-key-content ", region=" us-ashburn-1 "
),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert provider_secret.secret["key_content"] == "test-key-content"
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
self,
authenticated_client,
oraclecloud_provider,
):
create_response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {"secret": self._oraclecloud_secret()},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
self,
authenticated_client,
oraclecloud_provider,
):
create_response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
+48 -6
View File
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"filter_accounts": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
if isinstance(prowler_provider_kwargs.get("region"), str):
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"region": {prowler_provider_kwargs["region"]},
}
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
# in the provider itself, so it's not needed here.
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
**{k: v for k, v in prowler_provider_kwargs.items() if v},
}
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
prowler_provider_kwargs
)
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
return prowler_provider_kwargs
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
return prowler_provider_kwargs
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
if (
prowler_provider_kwargs.get("user")
and prowler_provider_kwargs.get("fingerprint")
and prowler_provider_kwargs.get("tenancy")
and (
prowler_provider_kwargs.get("key_content")
or prowler_provider_kwargs.get("key_file")
)
):
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
)
return prowler_provider_kwargs
def initialize_prowler_provider(
provider: Provider,
mutelist_processor: Processor | None = None,
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
if prowler_provider_kwargs.get("registry_token"):
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
return prowler_provider.test_connection(**image_kwargs)
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
prowler_provider_kwargs
)
return prowler_provider.test_connection(
**oraclecloud_kwargs,
provider_id=provider.uid,
raise_on_exception=False,
)
else:
return prowler_provider.test_connection(
**prowler_provider_kwargs,
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
"type": "string",
"description": "The OCID of the tenancy.",
},
"region": {
"type": "string",
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
},
"pass_phrase": {
"type": "string",
"description": "The passphrase for the private key, if encrypted.",
},
"region": {
"type": "string",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
},
},
"required": ["user", "fingerprint", "tenancy", "region"],
"required": ["user", "fingerprint", "tenancy"],
"anyOf": [
{"required": ["key_file"]},
{"required": ["key_content"]},
],
},
{
"type": "object",
+30 -3
View File
@@ -1672,6 +1672,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
validation_error.detail[f"secret/{key}"] = value
del validation_error.detail[key]
raise validation_error
return serializer.validated_data
class AwsProviderSecret(serializers.Serializer):
@@ -1813,14 +1814,32 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class LegacyOCIRegionField(serializers.Field):
def to_internal_value(self, data):
return data
def to_representation(self, value):
return value
class OracleCloudProviderSecret(serializers.Serializer):
user = serializers.CharField()
fingerprint = serializers.CharField()
key_file = serializers.CharField(required=False)
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
region = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = LegacyOCIRegionField(required=False, allow_null=True)
def validate(self, attrs):
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
{"key_file": "Either key_file or key_content must be provided."}
)
return attrs
class Meta:
resource_name = "provider-secrets"
@@ -1965,7 +1984,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -1997,7 +2020,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
return validated_attrs
Generated
+1 -1
View File
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.36.0"
version = "1.37.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.34.0"
PROWLER_API_VERSION="5.34.0"
PROWLER_UI_VERSION="5.35.0"
PROWLER_API_VERSION="5.35.0"
```
<Note>
@@ -0,0 +1 @@
Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.35.0"
prowler_version = "5.36.0"
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"
+419 -51
View File
@@ -417,6 +417,19 @@ class Jira:
message=init_error, file=os.path.basename(__file__)
)
@staticmethod
def _sanitize_summary(summary: str) -> str:
"""Normalize and truncate a Jira issue summary.
Args:
summary: Raw summary text.
Returns:
The summary collapsed to one line and limited to Jira's 255-character
summary maximum.
"""
return " ".join(summary.split())[:255]
@staticmethod
def _build_code_block_content(code_value: str) -> Optional[Dict]:
if not code_value:
@@ -1155,6 +1168,101 @@ class Jira:
return "#0000FF"
return "#000000" # Default black color for unknown severities
@staticmethod
def _adf_colored_strong_marks(color_mark_type: str, color: str) -> list[dict]:
"""Build ADF marks for bold text with a Jira color mark.
Args:
color_mark_type: Jira ADF color mark type, such as textColor or
backgroundColor.
color: Hex color value for the mark.
Returns:
ADF marks for strong colored text.
"""
return [
{"type": "strong"},
{"type": color_mark_type, "attrs": {"color": color}},
]
def _adf_severity_marks(
self, severity: str = "", severity_color: str | None = None
) -> list[dict]:
"""Build ADF marks for severity text.
Args:
severity: Finding severity used to derive a color when severity_color
is not provided.
severity_color: Optional explicit severity color.
Returns:
ADF marks for highlighted severity text.
"""
color = severity_color or self.get_severity_color(str(severity).lower())
return self._adf_colored_strong_marks("backgroundColor", color)
def _adf_status_marks(
self, status: str = "", status_color: str | None = None
) -> list[dict]:
"""Build ADF marks for status text.
Args:
status: Finding status used to derive a color when status_color is
not provided.
status_color: Optional explicit status color.
Returns:
ADF marks for colored status text.
"""
color = status_color or self.get_color_from_status(str(status).upper())
return self._adf_colored_strong_marks("textColor", color)
@staticmethod
def _adf_text_node(text: str, marks: list[dict] | None = None) -> dict:
"""Build an ADF text node.
Args:
text: Text content for the node.
marks: Optional ADF marks to apply to the text.
Returns:
ADF text node with optional marks.
"""
node = {"type": "text", "text": text}
if marks:
node["marks"] = marks
return node
def _adf_severity_text_node(
self, severity: str = "", severity_color: str | None = None
) -> dict:
"""Build an ADF text node for severity.
Args:
severity: Severity text to render.
severity_color: Optional explicit severity color.
Returns:
ADF text node with severity marks.
"""
return self._adf_text_node(
severity, self._adf_severity_marks(severity, severity_color)
)
def _adf_status_text_node(
self, status: str = "", status_color: str | None = None
) -> dict:
"""Build an ADF text node for status.
Args:
status: Status text to render.
status_color: Optional explicit status color.
Returns:
ADF text node with status marks.
"""
return self._adf_text_node(status, self._adf_status_marks(status, status_color))
def get_adf_description(
self,
check_id: str = "",
@@ -1293,19 +1401,9 @@ class Jira:
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": severity,
"marks": [
{"type": "strong"},
{
"type": "backgroundColor",
"attrs": {
"color": severity_color,
},
},
],
}
self._adf_severity_text_node(
severity, severity_color
)
],
}
],
@@ -1338,17 +1436,7 @@ class Jira:
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": status,
"marks": [
{"type": "strong"},
{
"type": "textColor",
"attrs": {"color": status_color},
},
],
}
self._adf_status_text_node(status, status_color)
],
}
],
@@ -1872,6 +1960,239 @@ class Jira:
],
}
def get_grouped_adf_description(
self,
check_id: str = "",
check_title: str = "",
check_description: str = "",
severity: str = "",
status: str = "",
provider: str = "",
service: str = "",
affected_failing_resources: int = 0,
last_seen: str = "",
failing_for: str = "",
grouped_resources: list[dict] | None = None,
resources_total: int = 0,
resources_shown: int = 0,
finding_group_url: str = "",
finding_group_link_text: str = "",
risk: str = "",
recommendation_text: str = "",
recommendation_url: str = "",
) -> dict:
"""Build a Jira ADF description for a grouped finding issue.
Args:
check_id: Finding check ID.
check_title: Finding check title.
check_description: Finding check description.
severity: Finding group severity.
status: Finding group status.
provider: Cloud provider name.
service: Provider service name.
affected_failing_resources: Number of failing resources in the group.
last_seen: Last time the finding group was seen.
failing_for: Duration the finding group has been failing.
grouped_resources: Resource rows to include in the grouped issue.
resources_total: Total number of resources in the group.
resources_shown: Number of resources rendered in this Jira issue.
finding_group_url: Optional URL for the full finding group.
finding_group_link_text: Optional link text for finding_group_url.
risk: Risk description for the check.
recommendation_text: Remediation recommendation text.
recommendation_url: Optional remediation recommendation URL.
Returns:
Jira ADF document describing the finding group.
"""
def _safe(value) -> str:
return str(value) if value not in (None, "") else "-"
def _text(value, marks: list[dict] | None = None) -> dict:
node = {"type": "text", "text": _safe(value)}
if marks:
node["marks"] = marks
return node
def _paragraph(value, marks: list[dict] | None = None) -> dict:
return {"type": "paragraph", "content": [_text(value, marks)]}
def _cell(value, marks: list[dict] | None = None) -> dict:
return {"type": "tableCell", "content": [_paragraph(value, marks)]}
def _content_cell(content: list[dict]) -> dict:
return {"type": "tableCell", "content": content}
def _append_link(content: list[dict], url: str) -> list[dict]:
if not url:
return content
link_node = {
"type": "text",
"text": url,
"marks": [{"type": "link", "attrs": {"href": url}}],
}
if content and content[-1].get("type") == "paragraph":
paragraph_content = content[-1].setdefault("content", [])
if paragraph_content:
last_inline = paragraph_content[-1]
if last_inline.get("type") != "text" or not last_inline.get(
"text", ""
).endswith(" "):
paragraph_content.append({"type": "text", "text": " "})
paragraph_content.append(link_node)
else:
content.append({"type": "paragraph", "content": [link_node]})
return content
def _row(cells: list[dict]) -> dict:
return {"type": "tableRow", "content": cells}
strong = [{"type": "strong"}]
code = [{"type": "code"}]
severity_marks = self._adf_severity_marks(severity)
status_marks = self._adf_status_marks(status)
recommendation_content = _append_link(
self._markdown_converter.convert(_safe(recommendation_text)),
recommendation_url,
)
main_rows = [
_row([_cell("Check Id", strong), _cell(check_id, code)]),
_row([_cell("Check Title", strong), _cell(check_title)]),
_row([_cell("Severity", strong), _cell(severity, severity_marks)]),
_row([_cell("Status", strong), _cell(status, status_marks)]),
_row([_cell("Provider", strong), _cell(provider, code)]),
_row([_cell("Service", strong), _cell(service, code)]),
_row(
[
_cell("Affected Failing Resources", strong),
_cell(affected_failing_resources, strong),
]
),
_row([_cell("Last Seen", strong), _cell(last_seen)]),
_row([_cell("Failing For", strong), _cell(failing_for)]),
_row(
[
_cell("Risk", strong),
_content_cell(self._markdown_converter.convert(_safe(risk))),
]
),
_row(
[
_cell("Recommendation", strong),
_content_cell(recommendation_content),
]
),
]
resource_rows = [
_row(
[
_cell("Resource", strong),
_cell("Resource UID", strong),
_cell("Provider", strong),
_cell("Service", strong),
_cell("Account / Tenant", strong),
_cell("Status", strong),
_cell("Severity", strong),
_cell("Region", strong),
_cell("Last Seen", strong),
_cell("Failing For", strong),
_cell("Triage", strong),
]
)
]
for resource in grouped_resources or []:
resource_status = resource.get("status")
resource_severity = str(resource.get("severity", "")).upper()
resource_status_marks = self._adf_status_marks(resource_status)
resource_severity_marks = self._adf_severity_marks(resource_severity)
resource_rows.append(
_row(
[
_cell(resource.get("resource_name"), code),
_cell(resource.get("resource_uid"), code),
_cell(resource.get("provider"), code),
_cell(resource.get("service"), code),
_cell(resource.get("provider_account"), code),
_cell(resource_status, resource_status_marks),
_cell(resource_severity, resource_severity_marks),
_cell(resource.get("region"), code),
_cell(resource.get("last_seen")),
_cell(resource.get("failing_for")),
_cell(resource.get("triage")),
]
)
)
content = [
_paragraph("Prowler has discovered the following Finding Group:"),
{"type": "table", "attrs": {"layout": "full-width"}, "content": main_rows},
]
content.extend(
[
{
"type": "heading",
"attrs": {"level": 2},
"content": [_text("Affected failing resources")],
},
{
"type": "table",
"attrs": {"layout": "full-width"},
"content": resource_rows,
},
]
)
if resources_total > resources_shown:
remaining_content = [
_text(f"Showing {resources_shown} of {resources_total} Findings.")
]
if finding_group_url and finding_group_link_text:
remaining_content = [
_text(
f"Showing {resources_shown} of {resources_total} Findings "
"in this Jira issue. "
),
_text(
finding_group_link_text,
[
{
"type": "link",
"attrs": {"href": finding_group_url},
}
],
),
]
content.append(
{
"type": "paragraph",
"content": remaining_content,
}
)
elif finding_group_url and finding_group_link_text:
content.append(
{
"type": "paragraph",
"content": [
_text(
finding_group_link_text,
[
{
"type": "link",
"attrs": {"href": finding_group_url},
}
],
),
],
}
)
return {"type": "doc", "version": 1, "content": content}
def send_findings(
self,
findings: list[Finding] = None,
@@ -1965,7 +2286,7 @@ class Jira:
summary_parts.append(finding.resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"[:255]
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
payload = {
"fields": {
@@ -2048,11 +2369,13 @@ class Jira:
self,
check_id: str = "",
check_title: str = "",
check_description: str = "",
severity: str = "",
status: str = "",
status_extended: str = "",
provider: str = "",
region: str = "",
service: str = "",
resource_uid: str = "",
resource_name: str = "",
risk: str = "",
@@ -2069,6 +2392,14 @@ class Jira:
issue_labels: list[str] = "",
finding_url: str = "",
tenant_info: str = "",
affected_failing_resources: int = 0,
grouped_resources: list[dict] | None = None,
resources_total: int = 0,
resources_shown: int = 0,
last_seen: str = "",
failing_for: str = "",
finding_group_url: str = "",
finding_group_link_text: str = "",
) -> bool:
"""
Send the finding to Jira
@@ -2076,11 +2407,13 @@ class Jira:
Args:
- check_id: The check ID
- check_title: The check title
- check_description: The check description
- severity: The severity
- status: The status
- status_extended: The status extended
- provider: The provider
- region: The region
- service: The service
- resource_uid: The resource UID
- resource_name: The resource name
- risk: The risk
@@ -2097,6 +2430,15 @@ class Jira:
- issue_labels: The issue labels
- finding_url: The finding URL
- tenant_info: The tenant info
- affected_failing_resources: The number of affected failing resources
- grouped_resources: The grouped resources to render, or None for a
single finding issue
- resources_total: The total resources in the finding group
- resources_shown: The resources shown in the Jira issue
- last_seen: The last time the finding group was seen
- failing_for: The duration the finding group has been failing
- finding_group_url: The finding group URL
- finding_group_link_text: The link text for the finding group URL
Raises:
- JiraRefreshTokenError: Failed to refresh the access token
@@ -2140,40 +2482,66 @@ class Jira:
status_color = self.get_color_from_status(status)
severity_color = self.get_severity_color(severity.lower())
adf_description = self.get_adf_description(
check_id=check_id,
check_title=check_title,
severity=severity.upper(),
severity_color=severity_color,
status=status,
status_color=status_color,
status_extended=status_extended,
provider=provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
remediation_code_native_iac=remediation_code_native_iac,
remediation_code_terraform=remediation_code_terraform,
remediation_code_cli=remediation_code_cli,
remediation_code_other=remediation_code_other,
resource_tags=resource_tags,
compliance=compliance,
finding_url=finding_url,
tenant_info=tenant_info,
)
if grouped_resources is not None:
adf_description = self.get_grouped_adf_description(
check_id=check_id,
check_title=check_title,
check_description=check_description,
severity=severity.upper(),
status=status,
provider=provider,
service=service,
affected_failing_resources=affected_failing_resources,
last_seen=last_seen,
failing_for=failing_for,
grouped_resources=grouped_resources,
resources_total=resources_total,
resources_shown=resources_shown,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
)
else:
adf_description = self.get_adf_description(
check_id=check_id,
check_title=check_title,
severity=severity.upper(),
severity_color=severity_color,
status=status,
status_color=status_color,
status_extended=status_extended,
provider=provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
remediation_code_native_iac=remediation_code_native_iac,
remediation_code_terraform=remediation_code_terraform,
remediation_code_cli=remediation_code_cli,
remediation_code_other=remediation_code_other,
resource_tags=resource_tags,
compliance=compliance,
finding_url=finding_url,
tenant_info=tenant_info,
)
summary_parts = ["[Prowler]"]
if severity:
summary_parts.append(severity.upper())
if check_id:
summary_parts.append(check_id)
if resource_uid:
if grouped_resources is not None:
summary_parts.append(
f"{affected_failing_resources} affected failing resources"
)
elif resource_uid:
summary_parts.append(resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"[:255]
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
payload = {
"fields": {
+1 -1
View File
@@ -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.35.0"
version = "5.36.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+301
View File
@@ -98,6 +98,41 @@ class TestJiraIntegration:
return found
return None
@staticmethod
def _find_link_mark_by_href(nodes: List[dict], href: str) -> Optional[dict]:
for node in nodes:
if node.get("type") == "text":
for mark in node.get("marks", []):
if (
mark.get("type") == "link"
and mark.get("attrs", {}).get("href") == href
):
return mark
found = TestJiraIntegration._find_link_mark_by_href(
node.get("content", []), href
)
if found:
return found
return None
@staticmethod
def _collect_link_texts_by_href(nodes: List[dict], href: str) -> List[str]:
link_texts: List[str] = []
for node in nodes:
if node.get("type") == "text" and any(
mark.get("type") == "link" and mark.get("attrs", {}).get("href") == href
for mark in node.get("marks", [])
):
link_texts.append(node.get("text", ""))
link_texts.extend(
TestJiraIntegration._collect_link_texts_by_href(
node.get("content", []), href
)
)
return link_texts
@staticmethod
def _find_table_row(rows: List[dict], header: str) -> dict:
for row in rows:
@@ -918,6 +953,12 @@ class TestJiraIntegration:
intro_text = intro_paragraph["content"][0]
assert intro_text["type"] == "text"
assert intro_text["text"] == "Prowler has discovered the following finding:"
assert all(
self._collect_text_from_cell({"content": node.get("content", [])})
!= "Summary"
for node in description_content
if node.get("type") == "heading"
)
table = description_content[1]
assert table["type"] == "table"
@@ -1201,6 +1242,218 @@ class TestJiraIntegration:
value_cell = row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
def test_get_grouped_adf_description_uses_capped_finding_group_link_copy(self):
finding_group_url = (
"https://security.example.com/findings?"
"filter%5Bcheck_id%5D=admincenter_users_admins_reduced_license_footprint&"
"expandedCheckId=admincenter_users_admins_reduced_license_footprint"
)
finding_group_link_text = "View the remaining grouped findings."
recommendation_url = (
"https://hub.prowler.com/check/"
"admincenter_users_admins_reduced_license_footprint"
)
adf_description = self.jira_integration.get_grouped_adf_description(
check_id="admincenter_users_admins_reduced_license_footprint",
check_title="Administrative user has no license or an allowed license",
check_description="Administrative users are assigned productivity licenses.",
severity="HIGH",
status="FAIL",
provider="m365",
service="exchange",
affected_failing_resources=123,
last_seen="Jul 09, 2026 11:38AM UTC",
failing_for="< 1 day",
grouped_resources=[
{
"resource_name": "rich@prowler.com",
"resource_uid": "3f9a216b-b66b-4d5d-a812-2ad538732cfb",
"provider": "m365",
"service": "exchange",
"provider_account": "ProwlerPro.onmicrosoft.com",
"status": "FAIL",
"severity": "high",
"region": "global",
"last_seen": "Jul 09, 2026 11:38AM UTC",
"failing_for": "< 1 day",
"triage": "Open",
}
],
resources_total=123,
resources_shown=100,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
risk="Productivity licenses on privileged identities create risk.",
recommendation_text="Maintain dedicated admin accounts.",
recommendation_url=recommendation_url,
)
assert adf_description["type"] == "doc"
assert self._find_empty_text_nodes(adf_description) == []
main_table = adf_description["content"][1]
main_rows = {}
for row in main_table["content"]:
key_cell, value_cell = row["content"]
main_rows[self._collect_text_from_cell(key_cell)] = (
self._collect_text_from_cell(value_cell)
)
assert (
main_rows["Check Id"]
== "admincenter_users_admins_reduced_license_footprint"
)
assert main_rows["Service"] == "exchange"
assert main_rows["Affected Failing Resources"] == "123"
assert (
main_rows["Risk"]
== "Productivity licenses on privileged identities create risk."
)
assert main_rows["Recommendation"] == (
"Maintain dedicated admin accounts. " + recommendation_url
)
assert "Finding Group Link" not in main_rows
assert "Region" not in main_rows
top_level_headings = [
self._collect_text_from_cell({"content": node.get("content", [])})
for node in adf_description["content"]
if node.get("type") == "heading"
]
assert "Risk" not in top_level_headings
assert "Recommendation" not in top_level_headings
assert "Summary" not in top_level_headings
def text_marks(cell: dict) -> list[dict]:
return cell["content"][0]["content"][0]["marks"]
severity_marks = text_marks(
self._find_table_row(main_table["content"], "Severity")["content"][1]
)
status_marks = text_marks(
self._find_table_row(main_table["content"], "Status")["content"][1]
)
assert {
"type": "backgroundColor",
"attrs": {"color": "#FFA500"},
} in severity_marks
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in status_marks
resource_table = next(
node
for node in adf_description["content"]
if node.get("type") == "table"
and self._collect_text_from_cell(node["content"][0]["content"][0])
== "Resource"
)
resource_cells = resource_table["content"][1]["content"]
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in text_marks(
resource_cells[5]
)
assert {
"type": "backgroundColor",
"attrs": {"color": "#FFA500"},
} in text_marks(resource_cells[6])
document_text = self._collect_text_from_cell(
{"content": adf_description["content"]}
)
assert (
"Administrative users are assigned productivity licenses."
not in document_text
)
assert "Affected failing resources" in document_text
capped_link_copy = (
f"Showing 100 of 123 Findings in this Jira issue. {finding_group_link_text}"
)
assert document_text.count(capped_link_copy) == 1
assert "Finding Group Link" not in document_text
assert recommendation_url in document_text
recommendation_link_mark = self._find_link_mark_by_href(
adf_description["content"], recommendation_url
)
assert recommendation_link_mark is not None
link_mark = self._find_link_mark_by_href(
adf_description["content"], finding_group_url
)
assert link_mark is not None
assert link_mark["attrs"]["href"] == finding_group_url
assert self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
) == [finding_group_link_text]
assert (
len(
self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
)
)
== 1
)
assert "filter%5Bcheck_id%5D=" in link_mark["attrs"]["href"]
assert "expandedCheckId=" in link_mark["attrs"]["href"]
def test_get_grouped_adf_description_includes_link_when_not_capped(self):
finding_group_url = (
"https://security.example.com/findings?"
"filter%5Bcheck_id%5D=s3_bucket_public_access&"
"expandedCheckId=s3_bucket_public_access"
)
finding_group_link_text = "View this grouped finding."
adf_description = self.jira_integration.get_grouped_adf_description(
check_id="s3_bucket_public_access",
check_title="S3 bucket public access",
severity="HIGH",
status="FAIL",
provider="aws",
service="s3",
affected_failing_resources=1,
grouped_resources=[
{
"resource_name": "bucket-a",
"resource_uid": "arn:aws:s3:::bucket-a",
"provider": "aws",
"service": "s3",
"provider_account": "production (123456789012)",
"status": "FAIL",
"severity": "high",
"region": "us-east-1",
"last_seen": "Jul 09, 2026 11:38AM UTC",
"failing_for": "< 1 day",
"triage": "Open",
}
],
resources_total=1,
resources_shown=1,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
)
document_text = self._collect_text_from_cell(
{"content": adf_description["content"]}
)
assert "Showing 1 of 1 Findings." not in document_text
assert "remaining Findings" not in document_text
assert document_text.count(finding_group_link_text) == 1
assert "Finding Group Link" not in document_text
main_table = adf_description["content"][1]
main_row_headers = [
self._collect_text_from_cell(row["content"][0])
for row in main_table["content"]
]
assert "Finding Group Link" not in main_row_headers
link_mark = self._find_link_mark_by_href(
adf_description["content"], finding_group_url
)
assert link_mark is not None
assert link_mark["attrs"]["href"] == finding_group_url
assert self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
) == [finding_group_link_text]
assert (
"filter%5Bcheck_id%5D=s3_bucket_public_access" in link_mark["attrs"]["href"]
)
assert "expandedCheckId=s3_bucket_public_access" in link_mark["attrs"]["href"]
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
@@ -1709,6 +1962,54 @@ class TestJiraIntegration:
assert result is True
mock_post.assert_called_once()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_send_finding_sanitizes_summary_control_characters(
self,
mock_post,
mock_get_issue_types,
mock_get_projects,
mock_cloud_id,
mock_get_access_token,
):
"""Test that Jira summary is sent as one line."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_issue_types = mock_get_issue_types
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"}
mock_post.return_value = mock_response
long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260
result = self.jira_integration.send_finding(
check_id=long_check_id,
check_title="Test Finding",
severity="High\n",
status="FAIL",
project_key="TEST",
issue_type="Bug",
affected_failing_resources=2,
grouped_resources=[],
)
assert result is True
payload = mock_post.call_args.kwargs["json"]
expected_summary = (
f"[Prowler] HIGH - {' '.join(long_check_id.split())} - "
"2 affected failing resources"
)[:255]
assert payload["fields"]["summary"] == expected_summary
assert len(payload["fields"]["summary"]) == 255
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
@@ -0,0 +1 @@
Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
@@ -0,0 +1 @@
OCI provider E2E tests no longer require or submit a region when adding or updating credentials
@@ -0,0 +1 @@
Provider wizard modal state no longer loops or closes before the launch step
@@ -24,10 +24,15 @@ interface AppSidebarContentProps {
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const pathname = usePathname();
const { permissions } = useAuth();
const { apiDocsUrl } = useRuntimeConfig();
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useAppSidebarMode((state) => state.mode);
const isCloudEnvironment = isCloud();
const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
return (
@@ -157,6 +157,7 @@ describe("getNavigationConfig", () => {
const billing = getNavigationConfig({
pathname: "/billing",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions,
})
.flatMap((section) => section.items)
@@ -183,17 +184,28 @@ describe("getNavigationConfig", () => {
const cloudItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions,
}).flatMap((section) => section.items);
const enterpriseItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: false,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const localItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
// Then
expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
expect(
enterpriseItems.find((item) => item.label === "Billing"),
).toBeUndefined();
expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
});
@@ -31,6 +31,7 @@ import {
interface NavigationConfigOptions {
pathname: string;
apiDocsUrl?: string | null;
cloudBillingEnabled?: boolean;
permissions?: RolePermissionAttributes;
}
@@ -106,6 +107,7 @@ export function filterNavigationByPermissions(
export function getNavigationConfig({
pathname,
apiDocsUrl = null,
cloudBillingEnabled = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
@@ -265,7 +267,7 @@ export function getNavigationConfig({
},
],
},
...(isCloudEnvironment
...(isCloudEnvironment && cloudBillingEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
@@ -9,6 +9,7 @@ import {
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import { WIZARD_FOOTER_ACTION_TYPE } from "../steps/footer-controls";
import type { ProviderWizardInitialData } from "../types";
import { useProviderWizardController } from "./use-provider-wizard-controller";
@@ -237,7 +238,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("closes the wizard after a successful connection test in update mode", async () => {
it("moves to launch step after a successful connection test in update mode", async () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
@@ -265,10 +266,83 @@ describe("useProviderWizardController", () => {
result.current.handleTestSuccess();
});
// Credential rotation skips the launch/schedule step.
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
});
it("keeps the footer setter stable and uses replacement action callbacks", () => {
// Given
const onOpenChange = vi.fn();
const firstOnBack = vi.fn();
const firstOnSecondaryAction = vi.fn();
const firstOnAction = vi.fn();
const latestOnBack = vi.fn();
const latestOnSecondaryAction = vi.fn();
const latestOnAction = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
const initialSetFooterConfig = result.current.setFooterConfig;
const firstFooterConfig = {
showBack: true,
backLabel: "Back",
onBack: firstOnBack,
showSecondaryAction: true,
secondaryActionLabel: "Cancel",
secondaryActionVariant: "outline" as const,
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onSecondaryAction: firstOnSecondaryAction,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: firstOnAction,
};
act(() => {
result.current.setFooterConfig(firstFooterConfig);
});
// When
act(() => {
result.current.setFooterConfig({
...firstFooterConfig,
onBack: latestOnBack,
onSecondaryAction: latestOnSecondaryAction,
onAction: latestOnAction,
});
});
act(() => {
result.current.resolvedFooterConfig.onBack?.();
result.current.resolvedFooterConfig.onSecondaryAction?.();
result.current.resolvedFooterConfig.onAction?.();
});
// Then
expect(result.current.setFooterConfig).toBe(initialSetFooterConfig);
expect(result.current.resolvedFooterConfig).toMatchObject({
showBack: true,
backLabel: "Back",
showSecondaryAction: true,
secondaryActionLabel: "Cancel",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
});
expect(firstOnBack).not.toHaveBeenCalled();
expect(firstOnSecondaryAction).not.toHaveBeenCalled();
expect(firstOnAction).not.toHaveBeenCalled();
expect(latestOnBack).toHaveBeenCalledTimes(1);
expect(latestOnSecondaryAction).toHaveBeenCalledTimes(1);
expect(latestOnAction).toHaveBeenCalledTimes(1);
});
it("does not override launch footer config in the controller", () => {
@@ -82,7 +82,7 @@ export function useProviderWizardController({
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
const [resolvedFooterConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
@@ -220,12 +220,6 @@ export function useProviderWizardController({
};
const handleTestSuccess = () => {
if (
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
) {
handleClose();
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
@@ -254,13 +248,11 @@ export function useProviderWizardController({
const docsLink = isProviderFlow
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
: DOCS_URLS.AWS_ORGANIZATIONS;
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
const modalTitle = getProviderWizardModalTitle(mode);
return {
currentStep,
docsLink,
footerConfig,
handleClose,
handleDialogOpenChange,
handleTestSuccess,
@@ -0,0 +1,122 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderWizardModal } from "./provider-wizard-modal";
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/hooks/use-scroll-hint", () => ({
useScrollHint: () => ({
containerRef: vi.fn(),
sentinelRef: vi.fn(),
showScrollHint: false,
}),
}));
vi.mock("@/components/providers/wizard/steps/connect-step", () => ({
ConnectStep: () => <div>Connect step</div>,
}));
vi.mock("@/components/providers/wizard/steps/credentials-step", () => ({
CredentialsStep: ({ onNext }: { onNext: () => void }) => (
<div>
<div>Credentials step</div>
<button type="button" onClick={onNext}>
Continue to validate connection
</button>
</div>
),
}));
vi.mock("@/components/providers/wizard/steps/test-connection-step", () => ({
TestConnectionStep: ({ onSuccess }: { onSuccess: () => void }) => (
<div>
<div>Test connection step</div>
<button type="button" onClick={onSuccess}>
Check connection
</button>
</div>
),
}));
vi.mock("@/components/providers/wizard/steps/launch-step", () => ({
LaunchStep: () => <div>Launch step</div>,
}));
vi.mock("@/components/providers/organizations/org-setup-form", () => ({
OrgSetupForm: () => <div>Organization setup</div>,
}));
vi.mock("@/components/providers/organizations/org-account-selection", () => ({
OrgAccountSelection: () => <div>Organization account selection</div>,
}));
vi.mock("@/components/providers/organizations/org-launch-scan", () => ({
OrgLaunchScan: () => <div>Organization launch scan</div>,
}));
describe("ProviderWizardModal", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
it("provides an accessible dialog description without requiring visible helper text", () => {
// Given
const onOpenChange = vi.fn();
// When
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
// Then
const dialog = screen.getByRole("dialog", { name: /adding a provider/i });
expect(dialog).toHaveAccessibleDescription(/connect or update a provider/i);
});
it("shows the launch progress step when update mode reaches launch", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<ProviderWizardModal
open
onOpenChange={onOpenChange}
initialData={{
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "production",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
}}
/>,
);
expect(await screen.findByText("Credentials step")).toBeVisible();
// When
await user.click(
screen.getByRole("button", { name: /continue to validate connection/i }),
);
await user.click(
await screen.findByRole("button", { name: /check connection/i }),
);
// Then
expect(screen.getByText("Launch step")).toBeVisible();
expect(screen.getByText("Launch Scan")).toBeVisible();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
@@ -33,9 +33,12 @@ import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
0,
PROVIDER_WIZARD_STEP.LAUNCH,
PROVIDER_WIZARD_STEP.LAUNCH + 1,
);
const PROVIDER_WIZARD_MODAL_DESCRIPTION =
"Connect or update a provider by adding account details, credentials, testing the connection, and launching a scan.";
interface ProviderWizardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -100,6 +103,7 @@ export function ProviderWizardModal({
<Modal
open={open}
onOpenChange={handleDialogOpenChange}
description={PROVIDER_WIZARD_MODAL_DESCRIPTION}
size="4xl"
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
>
@@ -0,0 +1,113 @@
import { act, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ConnectStep } from "./connect-step";
type ConnectStepUiState = {
showBack: boolean;
showAction: boolean;
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
};
type CapturedConnectAccountFormProps = {
onUiStateChange?: (state: ConnectStepUiState) => void;
};
const { capturedConnectAccountFormProps } = vi.hoisted(() => ({
capturedConnectAccountFormProps: {
current: null as CapturedConnectAccountFormProps | null,
},
}));
vi.mock("@/components/providers/workflow/forms", () => ({
ConnectAccountForm: (props: CapturedConnectAccountFormProps) => {
capturedConnectAccountFormProps.current = props;
return <div data-testid="connect-account-form" />;
},
}));
describe("ConnectStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
capturedConnectAccountFormProps.current = null;
useProviderWizardStore.getState().reset();
});
it("does not republish footer config for repeated unchanged form UI state", async () => {
// Given
const onFooterChange = vi.fn();
render(
<ConnectStep
onNext={vi.fn()}
onSelectOrganizations={vi.fn()}
onFooterChange={onFooterChange}
onProviderTypeChange={vi.fn()}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
// When
act(() => {
const unchangedUiState = {
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
};
capturedConnectAccountFormProps.current?.onUiStateChange?.(
unchangedUiState,
);
capturedConnectAccountFormProps.current?.onUiStateChange?.({
...unchangedUiState,
});
});
// Then
expect(onFooterChange).toHaveBeenCalledTimes(1);
});
it("publishes a new footer config when form UI state changes", async () => {
// Given
const onFooterChange = vi.fn();
render(
<ConnectStep
onNext={vi.fn()}
onSelectOrganizations={vi.fn()}
onFooterChange={onFooterChange}
onProviderTypeChange={vi.fn()}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
// When
act(() => {
capturedConnectAccountFormProps.current?.onUiStateChange?.({
showBack: true,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
isLoading: false,
});
});
// Then
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(2));
expect(onFooterChange.mock.calls.at(-1)?.[0]).toMatchObject({
showBack: true,
showAction: true,
actionDisabled: false,
});
});
});
@@ -15,6 +15,35 @@ import {
WizardFooterConfig,
} from "./footer-controls";
type ConnectStepUiState = {
showBack: boolean;
showAction: boolean;
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
};
const CONNECT_STEP_INITIAL_UI_STATE: ConnectStepUiState = {
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
};
function isSameConnectStepUiState(
current: ConnectStepUiState,
next: ConnectStepUiState,
) {
return (
current.showBack === next.showBack &&
current.showAction === next.showAction &&
current.actionLabel === next.actionLabel &&
current.actionDisabled === next.actionDisabled &&
current.isLoading === next.isLoading
);
}
interface ConnectStepProps {
onNext: () => void;
onSelectOrganizations: () => void;
@@ -31,13 +60,7 @@ export function ConnectStep({
const { setProvider, setVia, setSecretId, setMode } =
useProviderWizardStore();
const backHandlerRef = useRef<(() => void) | null>(null);
const [uiState, setUiState] = useState({
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
});
const [uiState, setUiState] = useState(CONNECT_STEP_INITIAL_UI_STATE);
const formId = "provider-wizard-connect-form";
@@ -54,6 +77,16 @@ export function ConnectStep({
onNext();
};
const handleUiStateChange = (nextUiState: ConnectStepUiState) => {
setUiState((currentUiState) => {
if (isSameConnectStepUiState(currentUiState, nextUiState)) {
return currentUiState;
}
return nextUiState;
});
};
useEffect(() => {
onFooterChange({
showBack: uiState.showBack,
@@ -75,7 +108,7 @@ export function ConnectStep({
onSuccess={handleSuccess}
onSelectOrganizations={onSelectOrganizations}
onProviderTypeChange={onProviderTypeChange}
onUiStateChange={setUiState}
onUiStateChange={handleUiStateChange}
onBackHandlerChange={(handler) => {
backHandlerRef.current = handler;
}}
@@ -48,16 +48,6 @@ export const OracleCloudCredentialsForm = ({
variant="bordered"
isRequired
/>
<WizardInputField
control={control}
name={ProviderCredentialFields.OCI_REGION}
type="text"
label="Region"
labelPlacement="inside"
placeholder="e.g. us-ashburn-1"
variant="bordered"
isRequired
/>
<WizardTextareaField
control={control}
name={ProviderCredentialFields.OCI_KEY_CONTENT}
+5
View File
@@ -79,6 +79,11 @@ export const Modal = ({
)}
</DialogHeader>
)}
{!title && description && (
<DialogDescription className="sr-only">
{description}
</DialogDescription>
)}
{children}
</DialogContent>
</Dialog>
-1
View File
@@ -180,7 +180,6 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.OCI_FINGERPRINT]: "",
[ProviderCredentialFields.OCI_KEY_CONTENT]: "",
[ProviderCredentialFields.OCI_TENANCY]: providerUid || "",
[ProviderCredentialFields.OCI_REGION]: "",
[ProviderCredentialFields.OCI_PASS_PHRASE]: "",
};
case "mongodbatlas":
+2
View File
@@ -105,6 +105,8 @@ describe("getRuntimeConfigClient", () => {
"reoDevClientId",
"sentryDsn",
"sentryEnvironment",
"stripePublishableKey",
"stripePublishableKeyV2",
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
+2
View File
@@ -21,6 +21,8 @@ const pickConfig = (
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
});
// Reads the <head> island once (memoized); all-null during SSR or if it's
@@ -389,10 +389,6 @@ export const buildOracleCloudSecret = (
[ProviderCredentialFields.OCI_TENANCY]:
providerUid ||
getFormValue(formData, ProviderCredentialFields.OCI_TENANCY),
[ProviderCredentialFields.OCI_REGION]: getFormValue(
formData,
ProviderCredentialFields.OCI_REGION,
),
[ProviderCredentialFields.OCI_PASS_PHRASE]: getFormValue(
formData,
ProviderCredentialFields.OCI_PASS_PHRASE,
+4
View File
@@ -10,6 +10,8 @@ export interface RuntimePublicConfig {
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
cloudBillingEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
}
export const RUNTIME_CONFIG_SCRIPT_ID = "__PROWLER_RUNTIME_CONFIG__";
@@ -25,4 +27,6 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
posthogHost: null,
reoDevClientId: null,
cloudBillingEnabled: false,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
+8
View File
@@ -49,5 +49,13 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
// server-side for V1/V2 routing). Default (unset) is off.
cloudBillingEnabled:
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false",
stripePublishableKey: readEnv(
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
),
stripePublishableKeyV2: readEnv(
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
),
};
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { readEnv } from "@/lib/runtime-env";
const publicRoutes = [
"/sign-in",
@@ -23,6 +24,8 @@ export default auth((req: NextAuthRequest) => {
const user = req.auth?.user;
const sessionError = req.auth?.error;
const cloudBillingEnabled =
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false";
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
if (sessionError && !isPublicRoute(pathname)) {
@@ -38,13 +41,16 @@ export default auth((req: NextAuthRequest) => {
return NextResponse.redirect(signInUrl);
}
if (
pathname.startsWith("/billing") &&
(!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
) {
return NextResponse.redirect(new URL("/profile", req.url));
}
if (user?.permissions) {
const permissions = user.permissions;
if (pathname.startsWith("/billing") && !permissions.manage_billing) {
return NextResponse.redirect(new URL("/profile", req.url));
}
if (
pathname.startsWith("/integrations") &&
!permissions.manage_integrations
-8
View File
@@ -224,7 +224,6 @@ export interface OCIProviderCredential {
userId?: string;
fingerprint?: string;
keyContent?: string;
region?: string;
}
// AlibabaCloud credential options
@@ -366,7 +365,6 @@ export class ProvidersPage extends BasePage {
readonly ociUserIdInput: Locator;
readonly ociFingerprintInput: Locator;
readonly ociKeyContentInput: Locator;
readonly ociRegionInput: Locator;
// AlibabaCloud provider form elements
readonly alibabacloudAccountIdInput: Locator;
@@ -510,7 +508,6 @@ export class ProvidersPage extends BasePage {
this.ociKeyContentInput = page.getByRole("textbox", {
name: /Private Key Content/i,
});
this.ociRegionInput = page.getByRole("textbox", { name: /Region/i });
// AlibabaCloud provider form inputs
this.alibabacloudAccountIdInput = page.getByRole("textbox", {
@@ -1300,9 +1297,6 @@ export class ProvidersPage extends BasePage {
if (credentials.keyContent) {
await this.ociKeyContentInput.fill(credentials.keyContent);
}
if (credentials.region) {
await this.ociRegionInput.fill(credentials.region);
}
}
async verifyOCICredentialsPageLoaded(): Promise<void> {
@@ -1313,7 +1307,6 @@ export class ProvidersPage extends BasePage {
await expect(this.ociUserIdInput).toBeVisible();
await expect(this.ociFingerprintInput).toBeVisible();
await expect(this.ociKeyContentInput).toBeVisible();
await expect(this.ociRegionInput).toBeVisible();
}
async verifyOCIUpdateCredentialsPageLoaded(): Promise<void> {
@@ -1324,7 +1317,6 @@ export class ProvidersPage extends BasePage {
await expect(this.ociUserIdInput).toBeVisible();
await expect(this.ociFingerprintInput).toBeVisible();
await expect(this.ociKeyContentInput).toBeVisible();
await expect(this.ociRegionInput).toBeVisible();
}
async selectAlibabaCloudProvider(): Promise<void> {
+6 -6
View File
@@ -667,7 +667,7 @@
**Preconditions:**
- Admin user authentication required (admin.auth.setup setup)
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT
- Remove any existing provider with the same Tenancy ID before starting the test
- This test must be run serially and never in parallel with other tests, as it requires the Tenancy ID not to be already registered beforehand.
@@ -678,7 +678,7 @@
3. Select OCI provider type
4. Fill provider details (tenancy ID and alias)
5. Verify OCI credentials page is loaded
6. Fill OCI credentials (user ID, fingerprint, key content, region)
6. Fill OCI credentials (user ID, fingerprint, key content)
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
@@ -696,7 +696,7 @@
- Connect account page displays OCI option
- Provider details form accepts tenancy ID and alias
- OCI credentials page loads
- Credentials form accepts all required fields (user ID, fingerprint, key content, region)
- Credentials form accepts all required fields (user ID, fingerprint, key content)
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by tenancy ID)
@@ -726,7 +726,7 @@
**Preconditions:**
- Admin user authentication required (admin.auth.setup setup)
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT
- An OCI provider with the specified Tenancy ID must already exist (run PROVIDER-E2E-012 first)
- This test must be run serially and never in parallel with other tests
@@ -738,7 +738,7 @@
4. Click "Update Credentials" option
5. Verify update credentials page is loaded
6. Verify OCI credentials form fields are visible (confirms providerUid is loaded)
7. Fill OCI credentials (user ID, fingerprint, key content, region)
7. Fill OCI credentials (user ID, fingerprint, key content)
8. Click Next to submit
9. Verify successful navigation to test connection page
@@ -756,7 +756,7 @@
- OCI provider row is visible in providers table
- Row actions dropdown opens and displays "Update Credentials" option
- Update credentials page URL contains correct parameters
- OCI credentials form displays all fields (tenancy ID, user ID, fingerprint, key content, region)
- OCI credentials form displays all required fields (tenancy ID, user ID, fingerprint, key content)
- Form submission succeeds (no silent failures due to missing provider UID)
- Successful redirect to test connection page
+2 -6
View File
@@ -1029,12 +1029,11 @@ test.describe("Add Provider", () => {
const userId = process.env.E2E_OCI_USER_ID ?? "";
const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? "";
const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? "";
const region = process.env.E2E_OCI_REGION ?? "";
// Setup before each test
test.beforeEach(async ({ page }) => {
test.skip(
!tenancyId || !userId || !fingerprint || !keyContent || !region,
!tenancyId || !userId || !fingerprint || !keyContent,
"OCI E2E env vars are not set",
);
providersPage = new ProvidersPage(page);
@@ -1071,7 +1070,6 @@ test.describe("Add Provider", () => {
userId: userId,
fingerprint: fingerprint,
keyContent: keyContent,
region: region,
};
// Navigate to providers page
@@ -1516,12 +1514,11 @@ test.describe("Update Provider Credentials", () => {
const userId = process.env.E2E_OCI_USER_ID ?? "";
const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? "";
const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? "";
const region = process.env.E2E_OCI_REGION ?? "";
// Setup before each test
test.beforeEach(async ({ page }) => {
test.skip(
!tenancyId || !userId || !fingerprint || !keyContent || !region,
!tenancyId || !userId || !fingerprint || !keyContent,
"OCI E2E env vars are not set",
);
providersPage = new ProvidersPage(page);
@@ -1543,7 +1540,6 @@ test.describe("Update Provider Credentials", () => {
userId: userId,
fingerprint: fingerprint,
keyContent: keyContent,
region: region,
};
// Navigate to providers page
@@ -20,6 +20,8 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogHost",
"reoDevClientId",
"cloudBillingEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
/**
-1
View File
@@ -280,7 +280,6 @@ export type OCICredentials = {
[ProviderCredentialFields.OCI_FINGERPRINT]: string;
[ProviderCredentialFields.OCI_KEY_CONTENT]: string;
[ProviderCredentialFields.OCI_TENANCY]: string;
[ProviderCredentialFields.OCI_REGION]: string;
[ProviderCredentialFields.OCI_PASS_PHRASE]?: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
+9 -1
View File
@@ -30,6 +30,15 @@ declare global {
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
// Cloud-only Stripe publishable keys (public; shipped to the browser).
// V1 = legacy billing, V2 = metronome.
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY */
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?: string;
UI_CLOUD_STRIPE_PUBLISHABLE_KEY?: string;
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2 */
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2?: string;
UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2?: string;
// Build-time public config
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
@@ -134,7 +143,6 @@ declare global {
E2E_OCI_USER_ID?: string;
E2E_OCI_FINGERPRINT?: string;
E2E_OCI_KEY_CONTENT?: string;
E2E_OCI_REGION?: string;
// E2E Alibaba Cloud
E2E_ALIBABACLOUD_ACCOUNT_ID?: string;
+20
View File
@@ -220,3 +220,23 @@ users:
expect(result.success).toBe(true);
});
});
describe("addCredentialsFormSchema - oraclecloud", () => {
const BASE_OCI_VALUES = {
[ProviderCredentialFields.PROVIDER_ID]: "provider-oci-1",
[ProviderCredentialFields.PROVIDER_TYPE]: "oraclecloud",
[ProviderCredentialFields.OCI_USER]: "ocid1.user.oc1..example",
[ProviderCredentialFields.OCI_FINGERPRINT]: "aa:bb:cc:dd",
[ProviderCredentialFields.OCI_KEY_CONTENT]:
"-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----",
[ProviderCredentialFields.OCI_TENANCY]: "ocid1.tenancy.oc1..example",
} as const;
it("accepts OCI API key credentials without region", () => {
const schema = addCredentialsFormSchema("oraclecloud");
const result = schema.safeParse(BASE_OCI_VALUES);
expect(result.success).toBe(true);
});
});
-3
View File
@@ -288,9 +288,6 @@ export const addCredentialsFormSchema = (
[ProviderCredentialFields.OCI_TENANCY]: z
.string()
.min(1, "Tenancy OCID is required"),
[ProviderCredentialFields.OCI_REGION]: z
.string()
.min(1, "Region is required"),
[ProviderCredentialFields.OCI_PASS_PHRASE]: z
.union([z.string(), z.literal("")])
.optional(),
Generated
+1 -1
View File
@@ -3553,7 +3553,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.35.0"
version = "5.36.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },