mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
Merge remote-tracking branch 'origin/PROWLER-1799-sdk-only-provider-property' into PROWLER-1801-fix-compliance-discovery-collision
# Conflicts: # prowler/config/config.py
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_3_levels
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
|
||||
"NAME",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
]
|
||||
|
||||
return get_section_containers_3_levels(
|
||||
aux,
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
|
||||
"NAME",
|
||||
)
|
||||
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
|
||||
1. Navigate to **Users** from the side menu.
|
||||
2. Click the delete button of your current user.
|
||||
|
||||
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
|
||||
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
|
||||
|
||||
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
|
||||
|
||||
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
|
||||
|
||||
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
|
||||
|
||||
|
||||
@@ -11,10 +11,15 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
|
||||
- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
|
||||
- `sdk_only` provider property (default `true`) and `Provider.get_app_providers()`, so a provider (built-in or external) stays CLI/SDK-only and hidden from the app unless it declares `sdk_only = False` [(#11427)](https://github.com/prowler-cloud/prowler/pull/11427)
|
||||
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
|
||||
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
|
||||
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
|
||||
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
|
||||
- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-1
@@ -438,7 +438,18 @@ def prowler():
|
||||
)
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
output_options = global_provider.get_output_options(args, bulk_checks_metadata)
|
||||
try:
|
||||
output_options = global_provider.get_output_options(
|
||||
args, bulk_checks_metadata
|
||||
)
|
||||
except NotImplementedError:
|
||||
# No provider-specific OutputOptions: use the generic default so the
|
||||
# run still produces output instead of aborting.
|
||||
from prowler.providers.common.models import default_output_options
|
||||
|
||||
output_options = default_output_options(
|
||||
global_provider, args, bulk_checks_metadata
|
||||
)
|
||||
|
||||
# Run the quick inventory for the provider if available
|
||||
if hasattr(args, "quick_inventory") and args.quick_inventory:
|
||||
|
||||
@@ -1863,7 +1863,9 @@
|
||||
"Id": "ELB.4",
|
||||
"Name": "Application load balancers should be configured to drop HTTP headers",
|
||||
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ELB.4",
|
||||
|
||||
@@ -151,8 +151,8 @@ def get_available_compliance_frameworks(provider=None):
|
||||
continue
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
# External compliance via entry points; a provider may be served by
|
||||
# several packages, so iterate every directory it contributes.
|
||||
# External per-provider compliance via entry points; a provider may be
|
||||
# served by several packages, so iterate every directory it contributes.
|
||||
ep_dirs = _get_ep_compliance_dirs()
|
||||
for prov, paths in ep_dirs.items():
|
||||
if provider and prov != provider:
|
||||
@@ -165,6 +165,32 @@ def get_available_compliance_frameworks(provider=None):
|
||||
name = file.name.removesuffix(".json")
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
# External multi-provider frameworks via the dedicated universal group;
|
||||
# filtered by supports_provider when a provider is given.
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
|
||||
try:
|
||||
module = ep.load()
|
||||
path = (
|
||||
module.__path__[0]
|
||||
if hasattr(module, "__path__")
|
||||
else os.path.dirname(module.__file__)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
continue
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
for file in os.scandir(path):
|
||||
if file.is_file() and file.name.endswith(".json"):
|
||||
name = file.name.removesuffix(".json")
|
||||
if provider:
|
||||
framework = load_compliance_framework_universal(file.path)
|
||||
if framework is None or not framework.supports_provider(provider):
|
||||
continue
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
return available_compliance_frameworks
|
||||
|
||||
|
||||
|
||||
@@ -478,9 +478,15 @@ class Compliance(BaseModel):
|
||||
compliance_framework_name
|
||||
not in bulk_compliance_frameworks
|
||||
):
|
||||
bulk_compliance_frameworks[
|
||||
compliance_framework_name
|
||||
] = load_compliance_framework(file_path)
|
||||
# External JSON: tolerate non-legacy
|
||||
# schemas (skip + warn) instead of aborting.
|
||||
framework = load_compliance_framework(
|
||||
file_path, fatal=False
|
||||
)
|
||||
if framework is not None:
|
||||
bulk_compliance_frameworks[
|
||||
compliance_framework_name
|
||||
] = framework
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -494,18 +500,26 @@ class Compliance(BaseModel):
|
||||
|
||||
# Testing Pending
|
||||
def load_compliance_framework(
|
||||
compliance_specification_file: str,
|
||||
) -> Compliance:
|
||||
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
|
||||
compliance_specification_file: str, fatal: bool = True
|
||||
) -> Optional[Compliance]:
|
||||
"""load_compliance_framework loads and parse a Compliance Framework Specification.
|
||||
|
||||
With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
|
||||
``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
|
||||
is returned.
|
||||
"""
|
||||
try:
|
||||
compliance_framework = Compliance.parse_file(compliance_specification_file)
|
||||
return Compliance.parse_file(compliance_specification_file)
|
||||
except ValidationError as error:
|
||||
logger.critical(
|
||||
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
|
||||
if fatal:
|
||||
logger.critical(
|
||||
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.warning(
|
||||
f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
return compliance_framework
|
||||
return None
|
||||
|
||||
|
||||
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
|
||||
@@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
|
||||
if compliance_root and os.path.isdir(compliance_root):
|
||||
_load_jsons_from_dir(compliance_root, provider, bulk)
|
||||
|
||||
# External multi-provider frameworks via the dedicated universal entry
|
||||
# point group, kept separate from the per-provider `prowler.compliance`
|
||||
# group so the legacy loader never parses a universal JSON. Built-ins
|
||||
# (already in bulk) win on a name collision.
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
|
||||
try:
|
||||
module = ep.load()
|
||||
ep_dir = (
|
||||
module.__path__[0]
|
||||
if hasattr(module, "__path__")
|
||||
else os.path.dirname(module.__file__)
|
||||
)
|
||||
if os.path.isdir(ep_dir):
|
||||
_load_jsons_from_dir(ep_dir, provider, bulk)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
|
||||
return bulk
|
||||
|
||||
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
|
||||
def compliance_row(requirement, attribute, finding=None):
|
||||
# Read attribute fields defensively: GenericCompliance is the
|
||||
# last-resort renderer for any framework, and provider-specific
|
||||
# schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
|
||||
# Section/SubSection/SubGroup/Service/Type/Comment fields.
|
||||
return GenericComplianceModel(
|
||||
Provider=(finding.provider if finding else compliance.Provider.lower()),
|
||||
Description=compliance.Description,
|
||||
AccountId=finding.account_uid if finding else "",
|
||||
Region=finding.region if finding else "",
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=getattr(attribute, "Section", None),
|
||||
Requirements_Attributes_SubSection=getattr(
|
||||
attribute, "SubSection", None
|
||||
),
|
||||
Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
|
||||
Requirements_Attributes_Service=getattr(attribute, "Service", None),
|
||||
Requirements_Attributes_Type=getattr(attribute, "Type", None),
|
||||
Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
|
||||
Status=finding.status if finding else "MANUAL",
|
||||
StatusExtended=(finding.status_extended if finding else "Manual check"),
|
||||
ResourceId=finding.resource_uid if finding else "manual_check",
|
||||
ResourceName=finding.resource_name if finding else "Manual check",
|
||||
CheckId=finding.check_id if finding else "manual",
|
||||
Muted=finding.muted if finding else False,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
)
|
||||
|
||||
for finding in findings:
|
||||
for requirement in compliance.Requirements:
|
||||
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
|
||||
if finding.check_id in requirement.Checks:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GenericComplianceModel(
|
||||
Provider=finding.provider,
|
||||
Description=compliance.Description,
|
||||
AccountId=finding.account_uid,
|
||||
Region=finding.region,
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_SubSection=attribute.SubSection,
|
||||
Requirements_Attributes_SubGroup=attribute.SubGroup,
|
||||
Requirements_Attributes_Service=attribute.Service,
|
||||
Requirements_Attributes_Type=attribute.Type,
|
||||
Requirements_Attributes_Comment=attribute.Comment,
|
||||
Status=finding.status,
|
||||
StatusExtended=finding.status_extended,
|
||||
ResourceId=finding.resource_uid,
|
||||
ResourceName=finding.resource_name,
|
||||
CheckId=finding.check_id,
|
||||
Muted=finding.muted,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
self._data.append(
|
||||
compliance_row(requirement, attribute, finding)
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
# Add manual requirements to the compliance output
|
||||
for requirement in compliance.Requirements:
|
||||
if not requirement.Checks:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GenericComplianceModel(
|
||||
Provider=compliance.Provider.lower(),
|
||||
Description=compliance.Description,
|
||||
AccountId="",
|
||||
Region="",
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_SubSection=attribute.SubSection,
|
||||
Requirements_Attributes_SubGroup=attribute.SubGroup,
|
||||
Requirements_Attributes_Service=attribute.Service,
|
||||
Requirements_Attributes_Type=attribute.Type,
|
||||
Requirements_Attributes_Comment=attribute.Comment,
|
||||
Status="MANUAL",
|
||||
StatusExtended="Manual check",
|
||||
ResourceId="manual_check",
|
||||
ResourceName="Manual check",
|
||||
CheckId="manual",
|
||||
Muted=False,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
self._data.append(compliance_row(requirement, attribute))
|
||||
|
||||
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
|
||||
return node
|
||||
|
||||
def _paragraph_with_text(self, text: str) -> Dict:
|
||||
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
|
||||
# ADF forbids empty text nodes; emit an empty paragraph instead.
|
||||
content = [self._create_text_node(text, None)] if text else []
|
||||
return {"type": "paragraph", "content": content}
|
||||
|
||||
@staticmethod
|
||||
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
|
||||
@@ -1118,6 +1120,18 @@ class Jira:
|
||||
tenant_info: str = "",
|
||||
) -> dict:
|
||||
|
||||
# ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
|
||||
def _safe(value: str) -> str:
|
||||
return value if (value and value.strip()) else "-"
|
||||
|
||||
check_id = _safe(check_id)
|
||||
check_title = _safe(check_title)
|
||||
status_extended = _safe(status_extended)
|
||||
provider = _safe(provider)
|
||||
region = _safe(region)
|
||||
resource_uid = _safe(resource_uid)
|
||||
resource_name = _safe(resource_name)
|
||||
|
||||
table_rows = [
|
||||
{
|
||||
"type": "tableRow",
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
|
||||
"CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"TTPs/Initial Access",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "elbv2",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsElbv2LoadBalancer",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
|
||||
"Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn <ALB_ARN> --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - <example_subnet_id1>\n - <example_subnet_id2>\n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
|
||||
"Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
|
||||
"Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n load_balancer_type = \"application\"\n subnets = [\"<example_subnet_id1>\", \"<example_subnet_id2>\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
|
||||
"Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
|
||||
|
||||
|
||||
class elbv2_alb_drop_invalid_header_fields_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for lb in elbv2_client.loadbalancersv2.values():
|
||||
if lb.type == "application":
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(),
|
||||
resource=lb,
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"ELBv2 ALB {lb.name} is configured to drop invalid "
|
||||
"header fields."
|
||||
)
|
||||
if lb.drop_invalid_header_fields != "true":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"ELBv2 ALB {lb.name} is not configured to drop "
|
||||
"invalid header fields."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -4,6 +4,7 @@ from os.path import isdir
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
|
||||
@@ -69,3 +70,15 @@ class Connection:
|
||||
|
||||
is_connected: bool = False
|
||||
error: Exception = None
|
||||
|
||||
|
||||
def default_output_options(provider, arguments, bulk_checks_metadata):
|
||||
"""Generic OutputOptions fallback for external providers that do not
|
||||
implement get_output_options, so the run still produces output instead of
|
||||
aborting. Honors arguments.output_filename and otherwise derives a name
|
||||
from the provider type."""
|
||||
output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
|
||||
output_options.output_filename = getattr(arguments, "output_filename", None) or (
|
||||
f"prowler-output-{provider.type}-{output_file_timestamp}"
|
||||
)
|
||||
return output_options
|
||||
|
||||
@@ -175,9 +175,7 @@ class Provider(ABC):
|
||||
|
||||
def get_summary_entity(self) -> tuple:
|
||||
"""Return (entity_type, audited_entities) for the summary table."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_summary_entity()"
|
||||
)
|
||||
return (self.type, getattr(self.identity, "account_id", ""))
|
||||
|
||||
def get_finding_output_data(self, _check_output) -> dict:
|
||||
"""Return provider-specific fields for Finding.generate_output()."""
|
||||
|
||||
@@ -37,6 +37,7 @@ class IAM(GCPService):
|
||||
display_name=account.get("displayName", ""),
|
||||
project_id=project_id,
|
||||
uniqueId=account.get("uniqueId", ""),
|
||||
disabled=account.get("disabled", False),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
|
||||
keys: list[Key] = []
|
||||
project_id: str
|
||||
uniqueId: str
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class AccessApproval(GCPService):
|
||||
|
||||
+6
-1
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
|
||||
resource_id=account.email,
|
||||
location=iam_client.region,
|
||||
)
|
||||
if account.uniqueId in sa_ids_used:
|
||||
if account.disabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Service Account {account.email} is disabled and cannot be used."
|
||||
)
|
||||
elif account.uniqueId in sa_ids_used:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
|
||||
else:
|
||||
|
||||
@@ -12,6 +12,7 @@ class Logging(GCPService):
|
||||
self.sinks = []
|
||||
self.metrics = []
|
||||
self._get_sinks()
|
||||
self._get_org_sinks()
|
||||
self._get_metrics()
|
||||
|
||||
def _get_sinks(self):
|
||||
@@ -39,6 +40,38 @@ class Logging(GCPService):
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_org_sinks(self):
|
||||
"""Fetch org-level sinks with includeChildren so child projects are not falsely failed."""
|
||||
org_ids = set()
|
||||
for project in self.projects.values():
|
||||
if project.organization:
|
||||
org_ids.add(project.organization.id)
|
||||
|
||||
for org_id in org_ids:
|
||||
try:
|
||||
request = self.client.sinks().list(parent=f"organizations/{org_id}")
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
|
||||
for sink in response.get("sinks", []):
|
||||
self.sinks.append(
|
||||
Sink(
|
||||
name=sink["name"],
|
||||
destination=sink["destination"],
|
||||
filter=sink.get("filter", "all"),
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=sink.get("includeChildren", False),
|
||||
)
|
||||
)
|
||||
|
||||
request = self.client.sinks().list_next(
|
||||
previous_request=request, previous_response=response
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_metrics(self):
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
@@ -76,6 +109,7 @@ class Sink(BaseModel):
|
||||
destination: str
|
||||
filter: str
|
||||
project_id: str
|
||||
include_children: bool = False
|
||||
|
||||
|
||||
class Metric(BaseModel):
|
||||
|
||||
+46
-15
@@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
|
||||
class logging_sink_created(Check):
|
||||
def execute(self) -> Check_Report_GCP:
|
||||
findings = []
|
||||
|
||||
# Map project_id -> sink for direct project-level sinks
|
||||
projects_with_logging_sink = {}
|
||||
for sink in logging_client.sinks:
|
||||
if sink.filter == "all":
|
||||
if sink.filter == "all" and not sink.include_children:
|
||||
projects_with_logging_sink[sink.project_id] = sink
|
||||
|
||||
# Collect org resource names that have a covering sink (includeChildren=True)
|
||||
covering_org_sinks = {}
|
||||
for sink in logging_client.sinks:
|
||||
if sink.filter == "all" and sink.include_children:
|
||||
covering_org_sinks[sink.project_id] = sink
|
||||
|
||||
for project in logging_client.project_ids:
|
||||
if project not in projects_with_logging_sink.keys():
|
||||
project_obj = logging_client.projects.get(project)
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=project_obj,
|
||||
resource_id=project,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
else:
|
||||
project_obj = logging_client.projects.get(project)
|
||||
|
||||
# Determine whether this project is covered by an org-level sink
|
||||
org = getattr(project_obj, "organization", None) if project_obj else None
|
||||
org_resource = f"organizations/{org.id}" if org else None
|
||||
covering_sink = (
|
||||
covering_org_sinks.get(org_resource) if org_resource else None
|
||||
)
|
||||
|
||||
if project in projects_with_logging_sink:
|
||||
sink = projects_with_logging_sink[project]
|
||||
sink_name = getattr(sink, "name", None) or "unknown"
|
||||
report = Check_Report_GCP(
|
||||
@@ -40,4 +44,31 @@ class logging_sink_created(Check):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
elif covering_sink:
|
||||
sink_name = getattr(covering_sink, "name", None) or "unknown"
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=covering_sink,
|
||||
resource_id=sink_name,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(
|
||||
sink_name if sink_name != "unknown" else "Logging Sink"
|
||||
),
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
else:
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=project_obj,
|
||||
resource_id=project,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic.v1 import ValidationError
|
||||
@@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import (
|
||||
TableLabels,
|
||||
UniversalComplianceRequirement,
|
||||
adapt_legacy_to_universal,
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
load_compliance_framework_universal,
|
||||
)
|
||||
from tests.lib.outputs.compliance.fixtures import (
|
||||
@@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation:
|
||||
],
|
||||
attributes_metadata=self._metadata(enum=["high", "low"]),
|
||||
)
|
||||
|
||||
|
||||
class TestGetBulkUniversalEntryPoints:
|
||||
"""Entry-point discovery for universal (multi-provider) compliance frameworks."""
|
||||
|
||||
@staticmethod
|
||||
def _write_universal_json(directory, filename, framework, display_name):
|
||||
data = {
|
||||
"framework": framework,
|
||||
"name": display_name,
|
||||
"version": "1.0",
|
||||
"description": "External multi-provider framework",
|
||||
"requirements": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Requirement 1",
|
||||
"description": "desc",
|
||||
"checks": {"fakeexternal": ["check_a"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(os.path.join(directory, filename), "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
@staticmethod
|
||||
def _entry_point(path):
|
||||
module = MagicMock()
|
||||
module.__path__ = [path]
|
||||
ep = MagicMock()
|
||||
ep.name = "fakeexternal"
|
||||
ep.group = "prowler.compliance.universal"
|
||||
ep.load.return_value = module
|
||||
return ep
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_includes_external_universal_framework(self, mock_list_modules, mock_ep):
|
||||
mock_list_modules.return_value = []
|
||||
with tempfile.TemporaryDirectory() as ep_dir:
|
||||
self._write_universal_json(
|
||||
ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom"
|
||||
)
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
mock_ep.assert_called_with(group="prowler.compliance.universal")
|
||||
assert "customuniversal_1.0" in bulk
|
||||
assert bulk["customuniversal_1.0"].framework == "CustomUniversal"
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_builtin_wins_over_external_on_name_collision(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as root,
|
||||
tempfile.TemporaryDirectory() as ep_dir,
|
||||
):
|
||||
builtin_sub = os.path.join(root, "builtinprov")
|
||||
os.makedirs(builtin_sub)
|
||||
self._write_universal_json(
|
||||
builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in"
|
||||
)
|
||||
builtin_module = MagicMock()
|
||||
builtin_module.module_finder.path = root
|
||||
builtin_module.name = "prowler.compliance.builtinprov"
|
||||
mock_list_modules.return_value = [builtin_module]
|
||||
|
||||
self._write_universal_json(
|
||||
ep_dir, "shared_1.0.json", "SharedFramework", "External"
|
||||
)
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "shared_1.0" in bulk
|
||||
assert bulk["shared_1.0"].name == "Built-in"
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_loads_all_frameworks_in_a_single_entry_point_path(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
"""All JSONs in one entry-point directory are added, not collapsed to one."""
|
||||
mock_list_modules.return_value = []
|
||||
with tempfile.TemporaryDirectory() as ep_dir:
|
||||
self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A")
|
||||
self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B")
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "fw_a_1.0" in bulk
|
||||
assert "fw_b_1.0" in bulk
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_merges_frameworks_from_multiple_packages_same_provider(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
"""Two packages under the same provider name are both discovered."""
|
||||
mock_list_modules.return_value = []
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as dir_a,
|
||||
tempfile.TemporaryDirectory() as dir_b,
|
||||
):
|
||||
self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A")
|
||||
self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B")
|
||||
mock_ep.return_value = [
|
||||
self._entry_point(dir_a),
|
||||
self._entry_point(dir_b),
|
||||
]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "pkg_a_1.0" in bulk
|
||||
assert "pkg_b_1.0" in bulk
|
||||
|
||||
@@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
Compliance_Requirement,
|
||||
Generic_Compliance_Requirement_Attribute,
|
||||
ISO27001_2013_Requirement_Attribute,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel
|
||||
@@ -198,3 +199,47 @@ class TestAWSGenericCompliance:
|
||||
), f"Expected 1 row driven by framework JSON, got {len(rows)}"
|
||||
assert rows[0].Requirements_Id == "req_in_framework"
|
||||
assert rows[0].CheckId == "service_check_in_framework"
|
||||
|
||||
def test_transform_tolerates_framework_specific_attribute_schema(self):
|
||||
"""GenericCompliance is the documented last-resort renderer, so it must not
|
||||
crash on a framework whose attribute schema lacks the universal fields
|
||||
(Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares
|
||||
none of them; missing fields must render as None instead of raising
|
||||
AttributeError and dropping the whole CSV."""
|
||||
framework_name = "ISO27001-2013-External"
|
||||
compliance = Compliance(
|
||||
Framework=framework_name,
|
||||
Name=framework_name,
|
||||
Provider="external",
|
||||
Version="",
|
||||
Description="Framework shipping a provider-specific attribute schema",
|
||||
Requirements=[
|
||||
Compliance_Requirement(
|
||||
Id="A.5.1.1",
|
||||
Description="Policies for information security",
|
||||
Attributes=[
|
||||
ISO27001_2013_Requirement_Attribute(
|
||||
Category="Information security policies",
|
||||
Objetive_ID="A.5.1",
|
||||
Objetive_Name="Management direction",
|
||||
Check_Summary="Policy is defined",
|
||||
)
|
||||
],
|
||||
Checks=["service_test_check_id"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
findings = [generate_finding_output(check_id="service_test_check_id")]
|
||||
|
||||
output = GenericCompliance(findings, compliance)
|
||||
|
||||
rows = [row for row in output.data if row.Status != "MANUAL"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0].Requirements_Id == "A.5.1.1"
|
||||
assert rows[0].Requirements_Attributes_Section is None
|
||||
assert rows[0].Requirements_Attributes_SubSection is None
|
||||
assert rows[0].Requirements_Attributes_SubGroup is None
|
||||
assert rows[0].Requirements_Attributes_Service is None
|
||||
assert rows[0].Requirements_Attributes_Type is None
|
||||
assert rows[0].Requirements_Attributes_Comment is None
|
||||
|
||||
@@ -1004,6 +1004,89 @@ class TestJiraIntegration:
|
||||
for mark in node.get("marks", [])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_empty_text_nodes(node) -> List[str]:
|
||||
# ADF forbids empty text nodes; collect any to assert the document is valid.
|
||||
empties: List[str] = []
|
||||
|
||||
def walk(current) -> None:
|
||||
if isinstance(current, dict):
|
||||
if current.get("type") == "text" and current.get("text", "") == "":
|
||||
empties.append(current.get("text", ""))
|
||||
for value in current.values():
|
||||
walk(value)
|
||||
elif isinstance(current, list):
|
||||
for item in current:
|
||||
walk(item)
|
||||
|
||||
walk(node)
|
||||
return empties
|
||||
|
||||
def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
|
||||
# A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
|
||||
# empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
|
||||
adf_description = self.jira_integration.get_adf_description(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="CRITICAL",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#FF0000",
|
||||
status_extended="Some status",
|
||||
provider="aws",
|
||||
region="eu-west-1",
|
||||
resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
|
||||
resource_name="",
|
||||
recommendation_text="",
|
||||
)
|
||||
|
||||
assert self._find_empty_text_nodes(adf_description) == []
|
||||
|
||||
table = adf_description["content"][1]
|
||||
resource_name_row = self._find_table_row(table["content"], "Resource Name")
|
||||
value_cell = resource_name_row["content"][1]
|
||||
assert self._collect_text_from_cell(value_cell) == "-"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, header",
|
||||
[
|
||||
("check_id", "Check Id"),
|
||||
("check_title", "Check Title"),
|
||||
("status_extended", "Status Extended"),
|
||||
("provider", "Provider"),
|
||||
("region", "Region"),
|
||||
("resource_uid", "Resource UID"),
|
||||
("resource_name", "Resource Name"),
|
||||
],
|
||||
)
|
||||
def test_get_adf_description_empty_plain_text_fields_render_placeholder(
|
||||
self, field, header
|
||||
):
|
||||
base_kwargs = dict(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="HIGH",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#00FF00",
|
||||
status_extended="Some status",
|
||||
provider="aws",
|
||||
region="us-east-1",
|
||||
resource_uid="resource-1",
|
||||
resource_name="resource-name",
|
||||
recommendation_text="",
|
||||
)
|
||||
base_kwargs[field] = ""
|
||||
|
||||
adf_description = self.jira_integration.get_adf_description(**base_kwargs)
|
||||
|
||||
assert self._find_empty_text_nodes(adf_description) == []
|
||||
|
||||
table = adf_description["content"][1]
|
||||
row = self._find_table_row(table["content"], header)
|
||||
value_cell = row["content"][1]
|
||||
assert self._collect_text_from_cell(value_cell) == "-"
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
from importlib import import_module
|
||||
from unittest import mock
|
||||
|
||||
from boto3 import client, resource
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_REGION_EU_WEST_1,
|
||||
AWS_REGION_EU_WEST_1_AZA,
|
||||
AWS_REGION_EU_WEST_1_AZB,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
CHECK_MODULE = (
|
||||
"prowler.providers.aws.services.elbv2."
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled."
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
)
|
||||
ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client"
|
||||
GLOBAL_PROVIDER_PATCH = ".".join(
|
||||
[
|
||||
"prowler.providers.common.provider.Provider",
|
||||
"get_global_provider",
|
||||
]
|
||||
)
|
||||
PASS_STATUS_EXTENDED = " ".join(
|
||||
[
|
||||
"ELBv2 ALB my-lb is configured to drop invalid",
|
||||
"header fields.",
|
||||
]
|
||||
)
|
||||
FAIL_STATUS_EXTENDED = (
|
||||
"ELBv2 ALB my-lb is not configured to drop invalid header fields."
|
||||
)
|
||||
|
||||
|
||||
def get_check_class():
|
||||
return getattr(
|
||||
import_module(CHECK_MODULE),
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled",
|
||||
)
|
||||
|
||||
|
||||
class Test_elbv2_alb_drop_invalid_header_fields_enabled:
|
||||
@mock_aws
|
||||
def test_elb_no_balancers(self):
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_dropping_invalid_header_fields(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
security_group = ec2.create_security_group(
|
||||
GroupName="a-security-group", Description="First One"
|
||||
)
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
subnet2 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.0/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
|
||||
)
|
||||
|
||||
lb = conn.create_load_balancer(
|
||||
Name="my-lb",
|
||||
Subnets=[subnet1.id, subnet2.id],
|
||||
SecurityGroups=[security_group.id],
|
||||
Scheme="internal",
|
||||
Type="application",
|
||||
)["LoadBalancers"][0]
|
||||
|
||||
conn.modify_load_balancer_attributes(
|
||||
LoadBalancerArn=lb["LoadBalancerArn"],
|
||||
Attributes=[
|
||||
{
|
||||
"Key": "routing.http.drop_invalid_header_fields.enabled",
|
||||
"Value": "true",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == PASS_STATUS_EXTENDED
|
||||
assert result[0].resource_id == "my-lb"
|
||||
assert result[0].resource_arn == lb["LoadBalancerArn"]
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_not_dropping_invalid_header_fields(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
security_group = ec2.create_security_group(
|
||||
GroupName="a-security-group", Description="First One"
|
||||
)
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
subnet2 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.0/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
|
||||
)
|
||||
|
||||
lb = conn.create_load_balancer(
|
||||
Name="my-lb",
|
||||
Subnets=[subnet1.id, subnet2.id],
|
||||
SecurityGroups=[security_group.id],
|
||||
Scheme="internal",
|
||||
Type="application",
|
||||
)["LoadBalancers"][0]
|
||||
|
||||
conn.modify_load_balancer_attributes(
|
||||
LoadBalancerArn=lb["LoadBalancerArn"],
|
||||
Attributes=[
|
||||
{
|
||||
"Key": "routing.http.drop_invalid_header_fields.enabled",
|
||||
"Value": "false",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == FAIL_STATUS_EXTENDED
|
||||
assert result[0].resource_id == "my-lb"
|
||||
assert result[0].resource_arn == lb["LoadBalancerArn"]
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_network_load_balancer_ignored(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
|
||||
conn.create_load_balancer(
|
||||
Name="my-nlb",
|
||||
Subnets=[subnet1.id],
|
||||
Scheme="internal",
|
||||
Type="network",
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
+173
-5
@@ -1300,6 +1300,48 @@ class TestCompliance:
|
||||
|
||||
assert "custom_1.0_ext" in frameworks
|
||||
|
||||
@patch("prowler.config.config.importlib.metadata.entry_points")
|
||||
def test_get_available_compliance_includes_external_universal(self, mock_ep):
|
||||
"""External universal frameworks under prowler.compliance.universal are
|
||||
listed, for a provider and for the provider=None case that feeds
|
||||
--compliance choices."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from prowler.config.config import get_available_compliance_frameworks
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
framework = {
|
||||
"framework": "CustomUniversal",
|
||||
"name": "Custom Universal",
|
||||
"version": "1.0",
|
||||
"description": "Multi-provider",
|
||||
"requirements": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "r",
|
||||
"description": "d",
|
||||
"checks": {"aws": ["c"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f:
|
||||
json.dump(framework, f)
|
||||
|
||||
module = MagicMock()
|
||||
module.__path__ = [tmpdir]
|
||||
ep = _make_entry_point(
|
||||
"anyname", "pkg.compliance", "prowler.compliance.universal"
|
||||
)
|
||||
ep.load.return_value = module
|
||||
mock_ep.side_effect = lambda group: (
|
||||
[ep] if group == "prowler.compliance.universal" else []
|
||||
)
|
||||
|
||||
assert "customuniversal_1.0" in get_available_compliance_frameworks("aws")
|
||||
assert "customuniversal_1.0" in get_available_compliance_frameworks(None)
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep):
|
||||
@@ -1339,6 +1381,49 @@ class TestCompliance:
|
||||
assert "custom_1.0_fakeexternal" in bulk
|
||||
assert bulk["custom_1.0_fakeexternal"].Framework == "Custom"
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_compliance_get_bulk_skips_non_legacy_external_json(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
"""A universal-schema JSON registered under prowler.compliance is skipped,
|
||||
not aborting the run via sys.exit."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
|
||||
mock_list_modules.return_value = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
json_data = {
|
||||
"framework": "Universal",
|
||||
"name": "Universal Framework",
|
||||
"version": "1.0",
|
||||
"description": "Multi-provider",
|
||||
"requirements": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "r",
|
||||
"description": "d",
|
||||
"checks": {"aws": ["c"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f:
|
||||
json.dump(json_data, f)
|
||||
|
||||
mock_module = MagicMock()
|
||||
mock_module.__path__ = [tmpdir]
|
||||
ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance")
|
||||
ep.load.return_value = mock_module
|
||||
mock_ep.return_value = [ep]
|
||||
|
||||
bulk = Compliance.get_bulk("aws")
|
||||
|
||||
assert "universal_1.0" not in bulk
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep):
|
||||
@@ -1881,11 +1966,64 @@ class TestBaseContractDefaults:
|
||||
FakeProviderNoHelpText.from_cli_args(MagicMock(), {})
|
||||
|
||||
def test_get_output_options_raises_not_implemented(self):
|
||||
"""Base Provider.get_output_options raises NotImplementedError."""
|
||||
"""Base Provider.get_output_options raises NotImplementedError; the
|
||||
generic default is applied at the call site via default_output_options."""
|
||||
provider = FakeProviderNoHelpText()
|
||||
with pytest.raises(NotImplementedError):
|
||||
provider.get_output_options(MagicMock(), {})
|
||||
|
||||
def test_default_output_options_builds_generic_default(self):
|
||||
"""default_output_options returns a generic ProviderOutputOptions so an
|
||||
external provider without get_output_options still produces output
|
||||
instead of aborting the run."""
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.models import (
|
||||
ProviderOutputOptions,
|
||||
default_output_options,
|
||||
)
|
||||
|
||||
provider = FakeProviderNoHelpText()
|
||||
arguments = Namespace(
|
||||
status=None,
|
||||
output_formats=None,
|
||||
output_directory=None,
|
||||
output_filename=None,
|
||||
verbose=None,
|
||||
only_logs=None,
|
||||
unix_timestamp=None,
|
||||
shodan=None,
|
||||
fixer=None,
|
||||
)
|
||||
|
||||
output_options = default_output_options(provider, arguments, {})
|
||||
|
||||
assert isinstance(output_options, ProviderOutputOptions)
|
||||
assert (
|
||||
output_options.output_filename
|
||||
== f"prowler-output-{provider.type}-{output_file_timestamp}"
|
||||
)
|
||||
|
||||
def test_default_output_options_honors_explicit_filename(self):
|
||||
"""A user-supplied output_filename is preserved by default_output_options."""
|
||||
from prowler.providers.common.models import default_output_options
|
||||
|
||||
provider = FakeProviderNoHelpText()
|
||||
arguments = Namespace(
|
||||
status=None,
|
||||
output_formats=None,
|
||||
output_directory=None,
|
||||
output_filename="custom-name",
|
||||
verbose=None,
|
||||
only_logs=None,
|
||||
unix_timestamp=None,
|
||||
shodan=None,
|
||||
fixer=None,
|
||||
)
|
||||
|
||||
output_options = default_output_options(provider, arguments, {})
|
||||
|
||||
assert output_options.output_filename == "custom-name"
|
||||
|
||||
def test_get_stdout_detail_raises_not_implemented(self):
|
||||
"""Base Provider.get_stdout_detail raises NotImplementedError."""
|
||||
provider = FakeProviderNoHelpText()
|
||||
@@ -1897,11 +2035,41 @@ class TestBaseContractDefaults:
|
||||
provider = FakeProviderNoHelpText()
|
||||
assert provider.get_finding_sort_key() is None
|
||||
|
||||
def test_get_summary_entity_raises_not_implemented(self):
|
||||
"""Base Provider.get_summary_entity raises NotImplementedError."""
|
||||
def test_get_summary_entity_returns_type_and_account_default(self):
|
||||
"""Base Provider.get_summary_entity returns (type, account_id) so the
|
||||
summary table is not silently dropped for providers that don't override
|
||||
it."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import PropertyMock
|
||||
|
||||
provider = FakeProviderNoHelpText()
|
||||
with pytest.raises(NotImplementedError):
|
||||
provider.get_summary_entity()
|
||||
with patch.object(
|
||||
type(provider),
|
||||
"identity",
|
||||
new_callable=PropertyMock,
|
||||
return_value=SimpleNamespace(account_id="acc-123"),
|
||||
):
|
||||
entity_type, audited_entities = provider.get_summary_entity()
|
||||
|
||||
assert entity_type == provider.type
|
||||
assert audited_entities == "acc-123"
|
||||
|
||||
def test_get_summary_entity_defaults_account_to_empty_string(self):
|
||||
"""When the identity has no account_id, audited_entities falls back to ''."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import PropertyMock
|
||||
|
||||
provider = FakeProviderNoHelpText()
|
||||
with patch.object(
|
||||
type(provider),
|
||||
"identity",
|
||||
new_callable=PropertyMock,
|
||||
return_value=SimpleNamespace(),
|
||||
):
|
||||
entity_type, audited_entities = provider.get_summary_entity()
|
||||
|
||||
assert entity_type == provider.type
|
||||
assert audited_entities == ""
|
||||
|
||||
def test_get_finding_output_data_raises_not_implemented(self):
|
||||
"""Base Provider.get_finding_output_data raises NotImplementedError."""
|
||||
|
||||
+57
@@ -179,3 +179,60 @@ class Test_iam_service_account_unused:
|
||||
assert result[1].project_id == GCP_PROJECT_ID
|
||||
assert result[1].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[1].resource == iam_client.service_accounts[1]
|
||||
|
||||
def test_iam_service_account_disabled(self):
|
||||
iam_client = mock.MagicMock()
|
||||
monitoring_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client",
|
||||
new=iam_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client",
|
||||
new=monitoring_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
|
||||
from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import (
|
||||
iam_service_account_unused,
|
||||
)
|
||||
|
||||
iam_client.project_ids = [GCP_PROJECT_ID]
|
||||
iam_client.region = GCP_US_CENTER1_LOCATION
|
||||
|
||||
iam_client.service_accounts = [
|
||||
ServiceAccount(
|
||||
name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com",
|
||||
email="disabled-sa@my-project.iam.gserviceaccount.com",
|
||||
display_name="Disabled service account",
|
||||
keys=[],
|
||||
project_id=GCP_PROJECT_ID,
|
||||
uniqueId="999888877776666",
|
||||
disabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
# The account is absent from the usage metrics, so a non-disabled
|
||||
# account here would FAIL. Being disabled must take precedence and
|
||||
# PASS, since a disabled account cannot authenticate or be used.
|
||||
monitoring_client.sa_api_metrics = set()
|
||||
monitoring_client.audit_config = {"max_unused_account_days": 30}
|
||||
|
||||
check = iam_service_account_unused()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used."
|
||||
)
|
||||
assert result[0].resource_id == iam_client.service_accounts[0].email
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[0].resource == iam_client.service_accounts[0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prowler.providers.gcp.services.logging.logging_service import Logging
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
@@ -66,3 +66,74 @@ class TestLoggingService:
|
||||
== "resource.type=gae_app AND severity>=ERROR"
|
||||
)
|
||||
assert logging_client.metrics[1].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_org_sinks_fetched_when_project_has_organization(self):
|
||||
"""_get_org_sinks() appends org-level sinks when projects have an org."""
|
||||
from prowler.providers.gcp.models import GCPOrganization, GCPProject
|
||||
|
||||
org_id = "999888777"
|
||||
provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
|
||||
provider.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.sinks().list().execute.return_value = {
|
||||
"sinks": [
|
||||
{
|
||||
"name": "org-sink",
|
||||
"destination": "storage.googleapis.com/org-bucket",
|
||||
"filter": "all",
|
||||
"includeChildren": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_client.sinks().list_next.return_value = None
|
||||
mock_client.projects().metrics().list().execute.return_value = {"metrics": []}
|
||||
mock_client.projects().metrics().list_next.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
|
||||
new=mock_is_api_active,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
logging_svc = Logging(provider)
|
||||
|
||||
org_sinks = [
|
||||
s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}"
|
||||
]
|
||||
assert len(org_sinks) == 1
|
||||
assert org_sinks[0].name == "org-sink"
|
||||
assert org_sinks[0].include_children is True
|
||||
assert org_sinks[0].filter == "all"
|
||||
|
||||
def test_org_sinks_skipped_when_no_organization(self):
|
||||
"""_get_org_sinks() adds nothing when projects have no organization."""
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
|
||||
new=mock_is_api_active,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
|
||||
new=mock_api_client,
|
||||
),
|
||||
):
|
||||
logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
|
||||
|
||||
org_sinks = [
|
||||
s for s in logging_svc.sinks if s.project_id.startswith("organizations/")
|
||||
]
|
||||
assert org_sinks == []
|
||||
|
||||
+176
-2
@@ -1,6 +1,6 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prowler.providers.gcp.models import GCPProject
|
||||
from prowler.providers.gcp.models import GCPOrganization, GCPProject
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_EU1_LOCATION,
|
||||
GCP_PROJECT_ID,
|
||||
@@ -268,6 +268,7 @@ class Test_logging_sink_created:
|
||||
sink.name = None
|
||||
sink.filter = "all"
|
||||
sink.project_id = GCP_PROJECT_ID
|
||||
sink.include_children = False
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
@@ -311,9 +312,10 @@ class Test_logging_sink_created:
|
||||
)
|
||||
|
||||
# Create a MagicMock sink object without name attribute
|
||||
sink = MagicMock(spec=["filter", "project_id"])
|
||||
sink = MagicMock(spec=["filter", "project_id", "include_children"])
|
||||
sink.filter = "all"
|
||||
sink.project_id = GCP_PROJECT_ID
|
||||
sink.include_children = False
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
@@ -336,3 +338,175 @@ class Test_logging_sink_created:
|
||||
assert result[0].resource_id == "unknown"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_EU1_LOCATION
|
||||
|
||||
def test_org_level_sink_with_include_children_passes(self):
|
||||
"""Projects covered by an org-level sink with includeChildren=True should PASS."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="org-sink",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=True,
|
||||
)
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == "org-sink"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_EU1_LOCATION
|
||||
|
||||
def test_org_level_sink_without_include_children_fails(self):
|
||||
"""Projects NOT covered by includeChildren should still FAIL if no direct project sink."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="org-sink-no-children",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=False,
|
||||
)
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == GCP_PROJECT_ID
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_project_sink_takes_precedence_over_org_sink(self):
|
||||
"""A direct project sink should be reported even when an org-level sink also covers the project."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="project-sink",
|
||||
destination="storage.googleapis.com/project-bucket",
|
||||
filter="all",
|
||||
project_id=GCP_PROJECT_ID,
|
||||
),
|
||||
Sink(
|
||||
name="org-sink",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=True,
|
||||
),
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == "project-sink"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
@@ -10,6 +10,23 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.29.2] (Prowler v5.29.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
QueryResultAttributes,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
type JsonApiErrorBody = {
|
||||
errors: Array<{ detail: string; status: string }>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
);
|
||||
},
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -220,4 +220,45 @@ describe("AccountsSelector", () => {
|
||||
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
|
||||
});
|
||||
|
||||
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one icon per selected account without deduping by provider type", async () => {
|
||||
const secondAws = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...providers[0].attributes,
|
||||
uid: "999999999999",
|
||||
alias: "Staging AWS",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={[providers[0], secondAws]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1", "provider-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
|
||||
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
|
||||
expect(
|
||||
within(trigger).getByText("2 Providers selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
@@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = {
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
gcp: <GCPProviderBadge width={18} height={18} />,
|
||||
kubernetes: <KS8ProviderBadge width={18} height={18} />,
|
||||
m365: <M365ProviderBadge width={18} height={18} />,
|
||||
github: <GitHubProviderBadge width={18} height={18} />,
|
||||
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
|
||||
iac: <IacProviderBadge width={18} height={18} />,
|
||||
image: <ImageProviderBadge width={18} height={18} />,
|
||||
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
|
||||
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
|
||||
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
vercel: <VercelProviderBadge width={18} height={18} />,
|
||||
okta: <OktaProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -158,10 +125,36 @@ export function AccountsSelector({
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{p && (
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={p.attributes.provider} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// One icon per selected account (no dedupe): two accounts of the same
|
||||
// provider show two icons, disambiguated by the UID tooltip on hover.
|
||||
const items = selectedIds
|
||||
.map((selectedId) =>
|
||||
providers.find((pr) => getProviderValue(pr) === selectedId),
|
||||
)
|
||||
.filter((p): p is ProviderProps => Boolean(p))
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
type: p.attributes.provider as ProviderType,
|
||||
tooltip: p.attributes.uid,
|
||||
}));
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} Providers selected</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack items={items} />
|
||||
<span className="truncate">
|
||||
{selectedIds.length} Providers selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,7 +201,6 @@ export function AccountsSelector({
|
||||
const isDisabled = disabledValuesSet.has(value);
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
const searchKeywords = [
|
||||
displayName,
|
||||
p.attributes.alias,
|
||||
@@ -228,7 +220,9 @@ export function AccountsSelector({
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => {
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one icon per selected type and a count in the trigger", async () => {
|
||||
const azure = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: { ...providers[0].attributes, provider: "azure" as const },
|
||||
};
|
||||
|
||||
render(
|
||||
<ProviderTypeSelector
|
||||
providers={[providers[0], azure]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(
|
||||
within(trigger).getByText("2 Provider Types selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -14,163 +18,6 @@ import {
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
const PROVIDER_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
icon: AWSProviderBadge,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
icon: AzureProviderBadge,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
icon: GCPProviderBadge,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
icon: KS8ProviderBadge,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
icon: M365ProviderBadge,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
icon: GitHubProviderBadge,
|
||||
},
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: {
|
||||
label: "Infrastructure as Code",
|
||||
icon: IacProviderBadge,
|
||||
},
|
||||
image: {
|
||||
label: "Container Registry",
|
||||
icon: ImageProviderBadge,
|
||||
},
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: {
|
||||
label: "MongoDB Atlas",
|
||||
icon: MongoDBAtlasProviderBadge,
|
||||
},
|
||||
alibabacloud: {
|
||||
label: "Alibaba Cloud",
|
||||
icon: AlibabaCloudProviderBadge,
|
||||
},
|
||||
cloudflare: {
|
||||
label: "Cloudflare",
|
||||
icon: CloudflareProviderBadge,
|
||||
},
|
||||
openstack: {
|
||||
label: "OpenStack",
|
||||
icon: OpenStackProviderBadge,
|
||||
},
|
||||
vercel: {
|
||||
label: "Vercel",
|
||||
icon: VercelProviderBadge,
|
||||
},
|
||||
okta: {
|
||||
label: "Okta",
|
||||
icon: OktaProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_DATA)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
|
||||
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
|
||||
);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
|
||||
<IconComponent width={24} height={24} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null;
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{renderIcon(providerType)}
|
||||
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_TYPE_DATA[providerType].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack
|
||||
items={(selectedTypes as ProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
type,
|
||||
tooltip: PROVIDER_TYPE_DATA[type].label,
|
||||
}))}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
|
||||
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
|
||||
keywords={[
|
||||
providerType,
|
||||
PROVIDER_TYPE_DATA[providerType].label,
|
||||
]}
|
||||
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
|
||||
roles,
|
||||
canBeExpelled,
|
||||
currentTenantId: canBeExpelled ? currentTenantId : undefined,
|
||||
// Users may only delete their own account; gate the delete action so the
|
||||
// UI matches the backend rule and never offers an action that would fail.
|
||||
isCurrentUser: user.id === currentUserId,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them. The
|
||||
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
describe("ProviderIconCell", () => {
|
||||
it("renders the shared provider-type icon for a known provider", async () => {
|
||||
render(<ProviderIconCell provider="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a '?' placeholder for a provider type missing from the map", () => {
|
||||
render(
|
||||
<ProviderIconCell
|
||||
provider={"future-provider" as unknown as ProviderType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,10 @@
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const PROVIDER_ICONS = {
|
||||
aws: AWSProviderBadge,
|
||||
azure: AzureProviderBadge,
|
||||
gcp: GCPProviderBadge,
|
||||
kubernetes: KS8ProviderBadge,
|
||||
m365: M365ProviderBadge,
|
||||
github: GitHubProviderBadge,
|
||||
googleworkspace: GoogleWorkspaceProviderBadge,
|
||||
iac: IacProviderBadge,
|
||||
image: ImageProviderBadge,
|
||||
oraclecloud: OracleCloudProviderBadge,
|
||||
mongodbatlas: MongoDBAtlasProviderBadge,
|
||||
alibabacloud: AlibabaCloudProviderBadge,
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
vercel: VercelProviderBadge,
|
||||
okta: OktaProviderBadge,
|
||||
} as const;
|
||||
|
||||
interface ProviderIconCellProps {
|
||||
provider: ProviderType;
|
||||
size?: number;
|
||||
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
|
||||
size = 26,
|
||||
className = "size-8 rounded-md bg-white",
|
||||
}: ProviderIconCellProps) => {
|
||||
const IconComponent = PROVIDER_ICONS[provider];
|
||||
|
||||
if (!IconComponent) {
|
||||
// Unknown provider types (present in the data but missing from the shared
|
||||
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
|
||||
if (!(provider in PROVIDER_TYPE_DATA)) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IconComponent width={size} height={size} />
|
||||
<ProviderTypeIcon type={provider} size={size} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
|
||||
|
||||
// A provider type the API may return but this UI build does not know about.
|
||||
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="badge">{children}</span>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="tooltip">{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProviderTypeIcon", () => {
|
||||
it("renders the badge for the given provider type", async () => {
|
||||
render(<ProviderTypeIcon type="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sized placeholder instead of crashing for an unknown type", () => {
|
||||
// Regression guard for #9991: an unknown provider type must not throw.
|
||||
const { container } = render(
|
||||
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("div")).toHaveStyle({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderTypeIconStack", () => {
|
||||
it("renders one icon per item without deduping by type", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: "aws", tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Two AWS accounts -> two AWS icons (no dedupe).
|
||||
expect(await screen.findAllByText("AWS")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows each item's tooltip text on the icon", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
|
||||
"account-uid-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses items beyond `max` into a +N badge", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
max={3}
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "1" },
|
||||
{ key: "b", type: "azure", tooltip: "2" },
|
||||
{ key: "c", type: "gcp", tooltip: "3" },
|
||||
{ key: "d", type: "github", tooltip: "4" },
|
||||
{ key: "e", type: "okta", tooltip: "5" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
|
||||
// First icon is shown; items sliced beyond `max` never reach the DOM.
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders known icons and skips unknown types without crashing", async () => {
|
||||
// Regression guard for #9991: an unknown type in the stack must not throw.
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
|
||||
// chunk instead of being eagerly bundled wherever a selector is imported.
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Single source of truth mapping each provider type to its human-readable
|
||||
* label and (lazy) badge component. Shared by the account and provider-type
|
||||
* selectors so both stay in sync on labels, icons, and sizing.
|
||||
*/
|
||||
export const PROVIDER_TYPE_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
|
||||
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
|
||||
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
|
||||
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
|
||||
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
|
||||
github: { label: "GitHub", icon: GitHubProviderBadge },
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
|
||||
image: { label: "Container Registry", icon: ImageProviderBadge },
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
|
||||
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
|
||||
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
|
||||
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
|
||||
vercel: { label: "Vercel", icon: VercelProviderBadge },
|
||||
okta: { label: "Okta", icon: OktaProviderBadge },
|
||||
};
|
||||
|
||||
interface ProviderTypeIconProps {
|
||||
type: ProviderType;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single provider-type badge with a sized placeholder fallback.
|
||||
*
|
||||
* Falls back to the placeholder for provider types missing from
|
||||
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
|
||||
* build does not). The `type` is statically typed as `ProviderType`, so this
|
||||
* only guards the runtime case — see #9991, which fixed the same crash class.
|
||||
*/
|
||||
export const ProviderTypeIcon = ({
|
||||
type,
|
||||
size = 18,
|
||||
}: ProviderTypeIconProps) => {
|
||||
const data = PROVIDER_TYPE_DATA[type];
|
||||
if (!data) return <IconPlaceholder width={size} height={size} />;
|
||||
|
||||
const Icon = data.icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
|
||||
<Icon width={size} height={size} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProviderTypeIconStackItem {
|
||||
/** Stable React key (account id for accounts, provider type for types). */
|
||||
key: string;
|
||||
type: ProviderType;
|
||||
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface ProviderTypeIconStackProps {
|
||||
items: ProviderTypeIconStackItem[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
|
||||
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
|
||||
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
|
||||
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
|
||||
* (delay 0), which would otherwise override an ancestor provider's delay.
|
||||
*/
|
||||
const IconWithTooltip = ({
|
||||
item,
|
||||
size,
|
||||
}: {
|
||||
item: ProviderTypeIconStackItem;
|
||||
size: number;
|
||||
}) => {
|
||||
const icon = (
|
||||
<span className="inline-flex shrink-0">
|
||||
<ProviderTypeIcon type={item.type} size={size} />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!item.tooltip) return icon;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>{icon}</TooltipTrigger>
|
||||
<TooltipContent side="top">{item.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders up to `max` provider-type icons followed by a `+N` badge for the
|
||||
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
|
||||
* passed (one per selection) — callers decide whether to dedupe.
|
||||
*/
|
||||
export const ProviderTypeIconStack = ({
|
||||
items,
|
||||
max = 3,
|
||||
size = 18,
|
||||
className,
|
||||
}: ProviderTypeIconStackProps) => {
|
||||
const visible = items.slice(0, max);
|
||||
const overflow = items.slice(max);
|
||||
const overflowLabel = overflow
|
||||
.map((item) => item.tooltip)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center gap-1", className)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{visible.map((item) => (
|
||||
<IconWithTooltip key={item.key} item={item} size={size} />
|
||||
))}
|
||||
</span>
|
||||
{overflow.length > 0 && (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
|
||||
+{overflow.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{overflowLabel && (
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{overflowLabel}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,10 @@ import { useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
|
||||
// Show configuration step (step 0 or editing configuration)
|
||||
if (isEditingConfig || currentStep === 0) {
|
||||
const providerOptions = providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected",
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
|
||||
? "Connected"
|
||||
: "Disconnected";
|
||||
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: isDisabled
|
||||
? `${connectionLabel} (Already in use)`
|
||||
: connectionLabel,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { InfoIcon } from "@/components/icons/Icons";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NO_PROVIDERS_ADDED_ACTION = {
|
||||
BUTTON: "button",
|
||||
LINK: "link",
|
||||
} as const;
|
||||
|
||||
interface NoProvidersAddedBaseProps {
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
|
||||
onOpenWizard: () => void;
|
||||
href?: never;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
|
||||
href: string;
|
||||
onOpenWizard?: never;
|
||||
}
|
||||
|
||||
type NoProvidersAddedProps =
|
||||
| NoProvidersAddedButtonProps
|
||||
| NoProvidersAddedLinkProps;
|
||||
|
||||
const renderCta = (props: NoProvidersAddedProps) => {
|
||||
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
>
|
||||
<Link href={props.href}>Get Started</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={props.onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-labelledby="no-providers-added-title"
|
||||
className={cn(
|
||||
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2
|
||||
id="no-providers-added-title"
|
||||
className="text-2xl font-bold text-gray-800 dark:text-white"
|
||||
>
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderCta(props)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
useRouter: () => ({
|
||||
refresh: refreshMock,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/table", () => ({
|
||||
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/add-provider-button", () => ({
|
||||
AddProviderButton: () => <button type="button">Add provider</button>,
|
||||
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
|
||||
<button type="button" onClick={onOpenWizard}>
|
||||
Add Provider
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-filters", () => ({
|
||||
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
|
||||
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
|
||||
<div data-testid="providers-filters">
|
||||
Filters
|
||||
{actions}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
|
||||
ProviderWizardModal: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
Provider wizard
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ProvidersAccountsView } from "./providers-accounts-view";
|
||||
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
const disconnectedProviders: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: false,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProvidersAccountsView", () => {
|
||||
it("keeps the same vertical spacing between filters and table as other views", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("shows a full page empty state without filters or table when there are no providers", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: /no providers configured/i }),
|
||||
).toHaveClass("min-h-[calc(100dvh-28rem)]");
|
||||
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the no providers CTA", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/providers?tab=connected&addProvider=true",
|
||||
);
|
||||
// Spy only after the URL setup so we measure what the component does on mount.
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
// The URL is cleaned via the History API (no RSC refetch). We must NOT
|
||||
// refresh/replace here: re-running the /providers Server Component on close
|
||||
// read as a full page reload. The provider-creation actions already
|
||||
// revalidatePath("/providers"), so the table is fresh behind the modal.
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
"",
|
||||
"/providers?tab=connected",
|
||||
);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
|
||||
// Given: no addProvider param in the URL, wizard opened via the CTA.
|
||||
searchParamsValue.current = "";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: open the wizard from the empty-state CTA, then close it.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then: nothing to clean and no refresh — the creation actions own the
|
||||
// data refresh via revalidatePath.
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps filters and table visible when providers are disconnected", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
|
||||
"flex",
|
||||
"flex-col",
|
||||
"gap-6",
|
||||
);
|
||||
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No Providers Configured"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the normal Add Provider button", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /add provider/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AddProviderButton } from "@/components/providers/add-provider-button";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
|
||||
import { ProvidersFilters } from "@/components/providers/providers-filters";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
@@ -11,6 +13,10 @@ import type {
|
||||
OrgWizardInitialData,
|
||||
ProviderWizardInitialData,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import {
|
||||
ADD_PROVIDER_SEARCH_PARAM,
|
||||
ADD_PROVIDER_SEARCH_VALUE,
|
||||
} from "@/lib/providers-navigation";
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
|
||||
providers,
|
||||
rows,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const hasNoProviders = providers.length === 0;
|
||||
const shouldOpenProviderWizardFromUrl =
|
||||
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
|
||||
() => shouldOpenProviderWizardFromUrl,
|
||||
);
|
||||
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
|
||||
ProviderWizardInitialData | undefined
|
||||
>(undefined);
|
||||
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
|
||||
const handleWizardOpenChange = (open: boolean) => {
|
||||
setIsProviderWizardOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
if (open) return;
|
||||
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
|
||||
// Only clean the one-shot ?addProvider intent from the URL bar, via the
|
||||
// History API so it does NOT trigger an RSC refetch. We must not refresh
|
||||
// here: the provider-creation actions (addProvider / addCredentialsProvider
|
||||
// / checkConnectionProvider) already revalidatePath("/providers"), so the
|
||||
// table updates behind the modal. A router.refresh()/replace() on close
|
||||
// re-ran the whole /providers Server Component, which read as a full reload.
|
||||
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(ADD_PROVIDER_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
query ? `${pathname}?${query}` : pathname,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
{hasNoProviders ? (
|
||||
<NoProvidersAdded
|
||||
action="button"
|
||||
containerClassName="min-h-[calc(100dvh-28rem)]"
|
||||
onOpenWizard={() => openProviderWizard()}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={handleWizardOpenChange}
|
||||
initialData={providerWizardInitialData}
|
||||
orgInitialData={orgWizardInitialData}
|
||||
refreshOnClose={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
// When false, the caller skips the post-close router.refresh() and relies on
|
||||
// the provider-creation actions' revalidatePath("/providers") to refresh the
|
||||
// data. Defaults to true so standalone callers keep refreshing.
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function useProviderWizardController({
|
||||
@@ -57,6 +61,7 @@ export function useProviderWizardController({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose = true,
|
||||
}: UseProviderWizardControllerProps) {
|
||||
const router = useRouter();
|
||||
const initialProviderId = initialData?.providerId ?? null;
|
||||
@@ -185,7 +190,9 @@ export function useProviderWizardController({
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
if (refreshOnClose) {
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderWizardModal({
|
||||
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
}: ProviderWizardModalProps) {
|
||||
const {
|
||||
backToProviderFlow,
|
||||
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
});
|
||||
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
|
||||
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
interface NoProvidersAddedProps {
|
||||
onOpenWizard: () => void;
|
||||
}
|
||||
|
||||
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -1,73 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
|
||||
|
||||
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No Connected Providers</div>,
|
||||
}));
|
||||
|
||||
describe("ScansProvidersEmptyState", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
});
|
||||
|
||||
it("shows the add provider message and opens the provider wizard", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
it("shows the add provider message with a providers page CTA", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("clears the launch scan URL intent before opening the provider wizard", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=completed&launchScan=true";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
|
||||
scroll: false,
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: /open add provider modal/i,
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
|
||||
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
|
||||
expect(cta.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("does not render the provider wizard in Scans", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no connected providers message", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
|
||||
|
||||
import { NoProvidersAdded } from "./no-providers-added";
|
||||
import { NoProvidersConnected } from "./no-providers-connected";
|
||||
|
||||
interface ScansProvidersEmptyStateProps {
|
||||
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
|
||||
export function ScansProvidersEmptyState({
|
||||
thereIsNoProviders,
|
||||
}: ScansProvidersEmptyStateProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
const openProviderWizard = () => {
|
||||
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProviderWizardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={openProviderWizard} />
|
||||
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
|
||||
) : (
|
||||
<NoProvidersConnected />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The forms pull in server actions (`@/actions/users/users`) that can't run in
|
||||
// jsdom, so stub them with identifiable markers to assert which modal opens.
|
||||
vi.mock("../forms", () => ({
|
||||
DeleteForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="delete-form">delete-form:{userId}</div>
|
||||
),
|
||||
EditForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="edit-form">edit-form:{userId}</div>
|
||||
),
|
||||
ExpelUserForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="expel-form">expel-form:{userId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
interface RowOptions {
|
||||
id?: string;
|
||||
isCurrentUser?: boolean;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
}
|
||||
|
||||
const createRow = ({
|
||||
id = "user-1",
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
}: RowOptions = {}) =>
|
||||
({
|
||||
original: {
|
||||
id,
|
||||
attributes: {
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
company_name: "Acme",
|
||||
role: { name: "admin" },
|
||||
},
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
},
|
||||
}) as unknown as Row<{ id: string }>;
|
||||
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
|
||||
};
|
||||
|
||||
describe("DataTableRowActions (users)", () => {
|
||||
it("always renders the Edit User action", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow()} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Delete User only for the current user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Delete User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User for another user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({})} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
// Only the non-destructive Edit action remains.
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Expel from organization"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({
|
||||
isCurrentUser: false,
|
||||
canBeExpelled: true,
|
||||
currentTenantId: "tenant-1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Delete User with destructive styling", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
const menuItem = screen
|
||||
.getByText("Delete User")
|
||||
.closest("[role='menuitem']");
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
expect(menuItem).toHaveClass("text-text-error-primary");
|
||||
});
|
||||
|
||||
it("opens the delete confirmation modal when Delete User is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ id: "user-42", isCurrentUser: true })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByText("Delete User"));
|
||||
|
||||
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-form")).toHaveTextContent(
|
||||
"delete-form:user-42",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ interface UserRowData {
|
||||
attributes?: UserRowAttributes;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
isCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<UserProps extends UserRowData> {
|
||||
@@ -57,6 +58,10 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
row.original.canBeExpelled === true && !!row.original.currentTenantId;
|
||||
const currentTenantId = row.original.currentTenantId;
|
||||
|
||||
// A user can only delete their own account (enforced by the backend), so the
|
||||
// delete action is shown exclusively for the current user's row.
|
||||
const canDeleteUser = row.original.isCurrentUser === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -74,14 +79,16 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
{canDeleteUser && (
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
)}
|
||||
{canExpelUser && currentTenantId && (
|
||||
<Modal
|
||||
open={isExpelOpen}
|
||||
@@ -104,22 +111,26 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
{(canExpelUser || canDeleteUser) && (
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{canDeleteUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdownDangerZone>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+12
-12
@@ -778,26 +778,26 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser-playwright",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/coverage-v8",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -978,10 +978,10 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "vitest",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
|
||||
export const ADD_PROVIDER_SEARCH_VALUE = "true";
|
||||
export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
|
||||
+4
-4
@@ -133,9 +133,9 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/browser": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
@@ -158,7 +158,7 @@
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.1.8",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
|
||||
|
||||
Generated
+103
-110
@@ -325,14 +325,14 @@ importers:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
babel-plugin-react-compiler:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
@@ -400,11 +400,11 @@ importers:
|
||||
specifier: 5.5.4
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
vitest-browser-react:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -737,6 +737,9 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@blazediff/core@1.9.1':
|
||||
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1':
|
||||
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
|
||||
|
||||
@@ -4795,54 +4798,54 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/browser-playwright@4.0.18':
|
||||
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
|
||||
'@vitest/browser-playwright@4.1.8':
|
||||
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/browser@4.0.18':
|
||||
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
|
||||
'@vitest/browser@4.1.8':
|
||||
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
|
||||
peerDependencies:
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
'@vitest/coverage-v8@4.1.8':
|
||||
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
'@vitest/browser': 4.1.8
|
||||
vitest: 4.1.8
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
||||
'@vitest/expect@4.1.8':
|
||||
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||
|
||||
'@vitest/mocker@4.0.18':
|
||||
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
|
||||
'@vitest/mocker@4.1.8':
|
||||
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0-0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
||||
'@vitest/runner@4.1.8':
|
||||
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
||||
'@vitest/snapshot@4.1.8':
|
||||
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||
|
||||
'@vitest/spy@4.0.18':
|
||||
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
||||
'@vitest/spy@4.1.8':
|
||||
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
'@vitest/utils@4.1.8':
|
||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
@@ -5048,8 +5051,8 @@ packages:
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
@@ -5650,9 +5653,6 @@ packages:
|
||||
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
@@ -7246,10 +7246,6 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -7537,6 +7533,7 @@ packages:
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -7829,8 +7826,8 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
@@ -8347,20 +8344,23 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18:
|
||||
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
||||
vitest@4.1.8:
|
||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.0.18
|
||||
'@vitest/browser-preview': 4.0.18
|
||||
'@vitest/browser-webdriverio': 4.0.18
|
||||
'@vitest/ui': 4.0.18
|
||||
'@vitest/browser-playwright': 4.1.8
|
||||
'@vitest/browser-preview': 4.1.8
|
||||
'@vitest/browser-webdriverio': 4.1.8
|
||||
'@vitest/coverage-istanbul': 4.1.8
|
||||
'@vitest/coverage-v8': 4.1.8
|
||||
'@vitest/ui': 4.1.8
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
@@ -8374,6 +8374,10 @@ packages:
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
@@ -9319,6 +9323,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blazediff/core@1.9.1': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
@@ -14261,29 +14267,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
playwright: 1.56.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.0.18
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pixelmatch: 7.1.0
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
ws: 8.20.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -14291,60 +14297,62 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
|
||||
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
'@vitest/utils': 4.1.8
|
||||
ast-v8-to-istanbul: 1.0.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
'@vitest/expect@4.1.8':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
'@vitest/runner@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/utils': 4.1.8
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
'@vitest/snapshot@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.0.18': {}
|
||||
'@vitest/spy@4.1.8': {}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
@@ -14604,7 +14612,7 @@ snapshots:
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
@@ -15273,8 +15281,6 @@ snapshots:
|
||||
iterator.prototype: 1.1.5
|
||||
safe-array-concat: 1.1.3
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
@@ -17280,10 +17286,6 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
@@ -17980,7 +17982,7 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
@@ -18487,31 +18489,31 @@ snapshots:
|
||||
terser: 5.47.1
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
|
||||
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
es-module-lexer: 1.7.0
|
||||
'@vitest/expect': 4.1.8
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/runner': 4.1.8
|
||||
'@vitest/snapshot': 4.1.8
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
@@ -18521,20 +18523,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.10.8
|
||||
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
jsdom: 27.4.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
|
||||
+27
-4
@@ -132,6 +132,24 @@ export async function addAWSProvider(
|
||||
await scansPage.verifyPageLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the providers page to settle and reports whether the data table is
|
||||
* present. With zero providers the page renders a full-page empty state
|
||||
* ("No Providers Configured") instead of the table, so callers must not assume
|
||||
* the table is always there.
|
||||
*/
|
||||
async function providersTableVisibleOrEmptyState(
|
||||
page: ProvidersPage,
|
||||
): Promise<boolean> {
|
||||
const emptyState = page.page.getByRole("region", {
|
||||
name: /no providers configured/i,
|
||||
});
|
||||
await expect(page.providersTable.or(emptyState)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
return page.providersTable.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
export async function deleteProviderIfExists(
|
||||
page: ProvidersPage,
|
||||
providerUID: string,
|
||||
@@ -140,7 +158,11 @@ export async function deleteProviderIfExists(
|
||||
|
||||
// Navigate to providers page
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
// With zero providers the page shows the empty state, not the table, so there
|
||||
// is nothing to delete.
|
||||
if (!(await providersTableVisibleOrEmptyState(page))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allRows = page.providersTable.locator("tbody tr");
|
||||
|
||||
@@ -180,7 +202,7 @@ export async function deleteProviderIfExists(
|
||||
// Provider not found, nothing to delete
|
||||
// Navigate back to providers page to ensure clean state
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,7 +239,8 @@ export async function deleteProviderIfExists(
|
||||
// Wait for modal to close (this indicates deletion was initiated)
|
||||
await expect(modal).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Navigate back to providers page to ensure clean state
|
||||
// Navigate back to providers page to ensure clean state. Deleting the last
|
||||
// provider reveals the empty state instead of an empty table.
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
}
|
||||
|
||||
@@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage {
|
||||
name: /Adding A Provider|Update Provider Credentials/i,
|
||||
});
|
||||
|
||||
// Button to add a new provider
|
||||
// Button to add a new provider. When providers exist this is the filter-bar
|
||||
// "Add Provider" control; with zero providers the page renders the empty
|
||||
// state whose CTA is labelled "Open Add Provider modal" (button on
|
||||
// /providers, link on /scans). Only one of these is ever in the DOM at once.
|
||||
this.addProviderButton = page
|
||||
.getByRole("button", {
|
||||
name: "Add Provider",
|
||||
@@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage {
|
||||
name: "Add Provider",
|
||||
exact: true,
|
||||
}),
|
||||
);
|
||||
)
|
||||
.or(page.getByRole("button", { name: "Open Add Provider modal" }))
|
||||
.or(page.getByRole("link", { name: "Open Add Provider modal" }));
|
||||
|
||||
// Table displaying existing providers
|
||||
this.providersTable = page.getByRole("table");
|
||||
|
||||
Vendored
+125
@@ -0,0 +1,125 @@
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
// Runtime (Node / Next.js)
|
||||
NODE_ENV: "development" | "production" | "test";
|
||||
NEXT_RUNTIME?: "nodejs" | "edge";
|
||||
|
||||
// Public client config
|
||||
NEXT_PUBLIC_API_BASE_URL: string;
|
||||
NEXT_PUBLIC_API_DOCS_URL?: string;
|
||||
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
|
||||
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
|
||||
NEXT_PUBLIC_SENTRY_DSN?: string;
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
|
||||
|
||||
// Auth (NextAuth)
|
||||
AUTH_URL: string;
|
||||
AUTH_SECRET: string;
|
||||
AUTH_TRUST_HOST?: "true" | "false";
|
||||
NEXTAUTH_URL?: string;
|
||||
|
||||
// Sentry (server / build)
|
||||
SENTRY_DSN?: string;
|
||||
SENTRY_ENVIRONMENT?: string;
|
||||
SENTRY_RELEASE?: string;
|
||||
SENTRY_ORG?: string;
|
||||
SENTRY_PROJECT?: string;
|
||||
SENTRY_AUTH_TOKEN?: string;
|
||||
|
||||
// Social OAuth
|
||||
SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string;
|
||||
SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string;
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string;
|
||||
|
||||
// Feature integrations
|
||||
PROWLER_MCP_SERVER_URL?: string;
|
||||
// JSON-encoded array, parsed in actions/feeds
|
||||
RSS_FEED_SOURCES?: string;
|
||||
|
||||
// Environment detection
|
||||
CI?: string;
|
||||
DOCKER?: string;
|
||||
KUBERNETES_SERVICE_HOST?: string;
|
||||
|
||||
// E2E test credentials (Playwright only)
|
||||
E2E_ADMIN_USER?: string;
|
||||
E2E_ADMIN_PASSWORD?: string;
|
||||
E2E_NEW_USER_PASSWORD?: string;
|
||||
E2E_MANAGE_CLOUD_PROVIDERS_USER?: string;
|
||||
E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string;
|
||||
E2E_INVITE_AND_MANAGE_USERS_USER?: string;
|
||||
E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string;
|
||||
E2E_UNLIMITED_VISIBILITY_USER?: string;
|
||||
E2E_UNLIMITED_VISIBILITY_PASSWORD?: string;
|
||||
E2E_MANAGE_INTEGRATIONS_USER?: string;
|
||||
E2E_MANAGE_INTEGRATIONS_PASSWORD?: string;
|
||||
E2E_MANAGE_ACCOUNT_USER?: string;
|
||||
E2E_MANAGE_ACCOUNT_PASSWORD?: string;
|
||||
E2E_MANAGE_SCANS_USER?: string;
|
||||
E2E_MANAGE_SCANS_PASSWORD?: string;
|
||||
E2E_ORGANIZATION_ID?: string;
|
||||
|
||||
// E2E AWS
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID?: string;
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY?: string;
|
||||
E2E_AWS_PROVIDER_SECRET_KEY?: string;
|
||||
E2E_AWS_PROVIDER_ROLE_ARN?: string;
|
||||
E2E_AWS_ORGANIZATION_ID?: string;
|
||||
E2E_AWS_ORGANIZATION_ROLE_ARN?: string;
|
||||
|
||||
// E2E Azure
|
||||
E2E_AZURE_SUBSCRIPTION_ID?: string;
|
||||
E2E_AZURE_CLIENT_ID?: string;
|
||||
E2E_AZURE_SECRET_ID?: string;
|
||||
E2E_AZURE_TENANT_ID?: string;
|
||||
|
||||
// E2E Microsoft 365
|
||||
E2E_M365_DOMAIN_ID?: string;
|
||||
E2E_M365_CLIENT_ID?: string;
|
||||
E2E_M365_TENANT_ID?: string;
|
||||
E2E_M365_SECRET_ID?: string;
|
||||
E2E_M365_CERTIFICATE_CONTENT?: string;
|
||||
|
||||
// E2E GCP
|
||||
E2E_GCP_PROJECT_ID?: string;
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string;
|
||||
|
||||
// E2E Kubernetes
|
||||
E2E_KUBERNETES_CONTEXT?: string;
|
||||
E2E_KUBERNETES_KUBECONFIG_PATH?: string;
|
||||
|
||||
// E2E GitHub
|
||||
E2E_GITHUB_USERNAME?: string;
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string;
|
||||
E2E_GITHUB_APP_ID?: string;
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string;
|
||||
E2E_GITHUB_ORGANIZATION?: string;
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string;
|
||||
|
||||
// E2E Oracle Cloud
|
||||
E2E_OCI_TENANCY_ID?: string;
|
||||
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;
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string;
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string;
|
||||
E2E_ALIBABACLOUD_ROLE_ARN?: string;
|
||||
|
||||
// E2E Google Workspace
|
||||
E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string;
|
||||
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string;
|
||||
E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user