mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-21 13:20:57 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b6214ade | ||
|
|
8d0573d89c | ||
|
|
34e4d25576 | ||
|
|
d4a33c0d1c | ||
|
|
4c3017e2ed | ||
|
|
627d3b6199 | ||
|
|
03f2ab46c9 | ||
|
|
7f1cdb82ae | ||
|
|
1bb3fc9bda | ||
|
|
463e8309f7 | ||
|
|
59a7d30a3e | ||
|
|
2ae1062e76 | ||
|
|
ecfea481ef | ||
|
|
eb039d6f77 | ||
|
|
af61400681 | ||
|
|
bcb6ddb950 | ||
|
|
dabed66b3f | ||
|
|
b0b3ecec00 | ||
|
|
34079dcc09 | ||
|
|
bb411eb4bc |
@@ -25,6 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -33,6 +34,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1 @@
|
||||
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
|
||||
for region in set(all_security_hub_regions):
|
||||
regions_status[region] = region in connection.enabled_regions
|
||||
|
||||
# Save regions information in the integration configuration
|
||||
# Persist the successful connection check and regions information
|
||||
with rls_transaction(tenant_id, using=MainRouter.default_db):
|
||||
integration.connected = True
|
||||
integration.connection_last_checked_at = datetime.now(tz=UTC)
|
||||
integration.configuration["regions"] = regions_status
|
||||
integration.save()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.configuration = {"send_only_fails": True}
|
||||
mock_integration.credentials = {} # Empty credentials, use provider
|
||||
mock_integration.connected = False
|
||||
mock_integration.connection_last_checked_at = None
|
||||
|
||||
# Mock tenant_id
|
||||
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
|
||||
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
|
||||
# Configure the test_connection to return our mock_connection
|
||||
mock_security_hub_class.test_connection = mock_test_connection
|
||||
|
||||
checked_at_before = datetime.now(tz=UTC)
|
||||
connected, security_hub = get_security_hub_client_from_integration(
|
||||
mock_integration, tenant_id, mock_findings
|
||||
)
|
||||
checked_at_after = datetime.now(tz=UTC)
|
||||
|
||||
assert connected is True
|
||||
assert security_hub == mock_security_hub
|
||||
assert mock_integration.connected is True
|
||||
assert mock_integration.connection_last_checked_at.tzinfo is UTC
|
||||
assert (
|
||||
checked_at_before
|
||||
<= mock_integration.connection_last_checked_at
|
||||
<= checked_at_after
|
||||
)
|
||||
mock_integration.save.assert_called_once()
|
||||
|
||||
# Verify SecurityHub was called once to create the client
|
||||
assert mock_security_hub_class.call_count == 1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Jira descriptions with inline code nested in bold or italic Markdown now render as valid ADF
|
||||
@@ -203,7 +203,9 @@ class MarkdownToADFConverter:
|
||||
if token_type == "text":
|
||||
result.extend(self._text_to_nodes(token.content, marks_stack))
|
||||
elif token_type == "code_inline":
|
||||
marks = self._clone_marks(marks_stack)
|
||||
marks = self._clone_marks(
|
||||
[mark for mark in marks_stack if mark["type"] == "link"]
|
||||
)
|
||||
marks.append({"type": "code"})
|
||||
result.append(self._create_text_node(token.content, marks))
|
||||
elif token_type in {"softbreak", "hardbreak"}:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from random import getrandbits
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from py_ocsf_models.events.base_event import SeverityID, StatusID
|
||||
from py_ocsf_models.events.findings.detection_finding import (
|
||||
@@ -345,36 +346,68 @@ def _build_analytic(finding: Finding) -> Analytic:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _load_mitre_technique_map(provider: str) -> Dict[str, dict]:
|
||||
"""Load and cache MITRE ATT&CK techniques for a provider."""
|
||||
try:
|
||||
mitre_file = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"compliance",
|
||||
provider,
|
||||
f"mitre_attack_{provider}.json",
|
||||
)
|
||||
with open(mitre_file) as file:
|
||||
data = json.load(file)
|
||||
return {
|
||||
requirement["Id"]: requirement
|
||||
for requirement in data.get("Requirements", [])
|
||||
}
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _build_mitre_attacks(finding: Finding) -> Optional[List[MITREAttack]]:
|
||||
"""Build OCSF MITREAttack objects from MITRE-ATTACK metadata.
|
||||
"""Build OCSF MITREAttack objects from finding compliance technique IDs.
|
||||
|
||||
Args:
|
||||
finding (Finding): Finding with compliance metadata attached to its check metadata.
|
||||
finding (Finding): Finding with MITRE ATT&CK compliance technique IDs.
|
||||
|
||||
Returns:
|
||||
Optional[List[MITREAttack]]: MITRE attacks derived from metadata, or None
|
||||
when the finding has no MITRE-ATTACK metadata.
|
||||
Optional[List[MITREAttack]]: MITRE attacks for known provider techniques,
|
||||
or None when none can be built.
|
||||
"""
|
||||
technique_map = _load_mitre_technique_map(finding.provider)
|
||||
attacks = []
|
||||
for compliance in finding.metadata.Compliance or []:
|
||||
if compliance.Framework.upper() != "MITRE-ATTACK":
|
||||
for technique_id in finding.compliance.get("MITRE-ATTACK", []):
|
||||
requirement = technique_map.get(technique_id)
|
||||
if not requirement:
|
||||
continue
|
||||
|
||||
for requirement in compliance.Requirements:
|
||||
technique = Technique(
|
||||
uid=requirement.Id,
|
||||
name=requirement.Name,
|
||||
src_url=requirement.TechniqueURL,
|
||||
technique_name = requirement.get("Name")
|
||||
if not technique_name:
|
||||
logger.warning(
|
||||
f"Skipping MITRE ATT&CK technique {technique_id} for provider {finding.provider}: missing Name"
|
||||
)
|
||||
for tactic_name in requirement.Tactics:
|
||||
attacks.append(
|
||||
MITREAttack(
|
||||
technique=technique,
|
||||
tactic=Tactic(name=tactic_name),
|
||||
)
|
||||
continue
|
||||
technique = Technique(
|
||||
uid=technique_id,
|
||||
name=technique_name,
|
||||
src_url=requirement.get("TechniqueURL"),
|
||||
)
|
||||
for tactic_name in requirement.get("Tactics", []):
|
||||
attacks.append(
|
||||
MITREAttack(
|
||||
technique=technique,
|
||||
tactic=Tactic(name=tactic_name),
|
||||
)
|
||||
)
|
||||
|
||||
return attacks if attacks else None
|
||||
return attacks or None
|
||||
|
||||
|
||||
# NOTE: Copied from api/src/backend/api/uuid_utils.py (datetime_to_uuid7)
|
||||
|
||||
@@ -23,11 +23,55 @@ from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraSendFindingsResponseError,
|
||||
JiraTestConnectionError,
|
||||
)
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
from prowler.lib.outputs.jira.jira import Jira, MarkdownToADFConverter
|
||||
|
||||
TEST_DATETIME = "2023-01-01T12:01:01+00:00"
|
||||
|
||||
|
||||
class TestMarkdownToADFConverter:
|
||||
def setup_method(self):
|
||||
self.converter = MarkdownToADFConverter()
|
||||
|
||||
def test_inline_code_nested_in_strong_has_only_code_mark(self):
|
||||
result = self.converter.convert("**before `code` after**")
|
||||
|
||||
assert result[0]["content"] == [
|
||||
{"type": "text", "text": "before ", "marks": [{"type": "strong"}]},
|
||||
{"type": "text", "text": "code", "marks": [{"type": "code"}]},
|
||||
{"type": "text", "text": " after", "marks": [{"type": "strong"}]},
|
||||
]
|
||||
|
||||
def test_inline_code_nested_in_emphasis_has_only_code_mark(self):
|
||||
result = self.converter.convert("*before `code` after*")
|
||||
|
||||
assert result[0]["content"] == [
|
||||
{"type": "text", "text": "before ", "marks": [{"type": "em"}]},
|
||||
{"type": "text", "text": "code", "marks": [{"type": "code"}]},
|
||||
{"type": "text", "text": " after", "marks": [{"type": "em"}]},
|
||||
]
|
||||
|
||||
def test_inline_code_in_link_preserves_link_and_code_marks(self):
|
||||
result = self.converter.convert("[`code`](https://example.com)")
|
||||
|
||||
assert result[0]["content"][0]["marks"] == [
|
||||
{"type": "link", "attrs": {"href": "https://example.com"}},
|
||||
{"type": "code"},
|
||||
]
|
||||
|
||||
def test_inline_code_in_emphasized_link_preserves_link_and_code_marks(self):
|
||||
result = self.converter.convert("*[`code`](https://example.com)*")
|
||||
|
||||
assert result[0]["content"][0]["marks"] == [
|
||||
{"type": "link", "attrs": {"href": "https://example.com"}},
|
||||
{"type": "code"},
|
||||
]
|
||||
|
||||
def test_standalone_inline_code_has_code_mark(self):
|
||||
result = self.converter.convert("`code`")
|
||||
|
||||
assert result[0]["content"][0]["marks"] == [{"type": "code"}]
|
||||
|
||||
|
||||
class TestJiraIntegration:
|
||||
@pytest.fixture(autouse=True)
|
||||
@patch.object(Jira, "get_auth", return_value=None)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
@@ -28,11 +29,6 @@ from py_ocsf_models.objects.resource_details import ResourceDetails
|
||||
from pydantic.v1 import BaseModel as V1BaseModel
|
||||
|
||||
from prowler.config.config import prowler_version
|
||||
from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
Mitre_Requirement,
|
||||
Mitre_Requirement_Attribute_AWS,
|
||||
)
|
||||
from prowler.lib.outputs.ocsf.ocsf import OCSF
|
||||
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
|
||||
from tests.providers.aws.utils import AWS_REGION_EU_WEST_1
|
||||
@@ -148,89 +144,150 @@ class TestOCSF:
|
||||
1619600000, tz=timezone.utc
|
||||
)
|
||||
|
||||
def test_transform_mitre_attacks_populated(self):
|
||||
def test_transform_mitre_attacks_from_multiple_finding_compliance_ids(self):
|
||||
finding = generate_finding_output(
|
||||
provider="aws",
|
||||
compliance={"MITRE-ATTACK": ["T4242"]},
|
||||
compliance={"MITRE-ATTACK": ["T1078", "T1098"]},
|
||||
check_id="iam_user_mfa_enabled_console_access",
|
||||
check_title="IAM users with console access have MFA enabled",
|
||||
service_name="iam",
|
||||
)
|
||||
finding.metadata.Compliance = [
|
||||
Compliance(
|
||||
Framework="MITRE-ATTACK",
|
||||
Name="MITRE ATT&CK compliance framework",
|
||||
Provider="AWS",
|
||||
Version="",
|
||||
Description="MITRE ATT&CK test framework",
|
||||
Requirements=[
|
||||
Mitre_Requirement(
|
||||
Name="Synthetic Valid Accounts",
|
||||
Id="T4242",
|
||||
Tactics=["Persistence", "Privilege Escalation"],
|
||||
SubTechniques=[],
|
||||
Description="Synthetic MITRE technique for OCSF tests.",
|
||||
Platforms=["IaaS"],
|
||||
TechniqueURL="https://attack.mitre.org/techniques/T4242/",
|
||||
Attributes=[
|
||||
Mitre_Requirement_Attribute_AWS(
|
||||
AWSService="AWS IAM",
|
||||
Category="Protect",
|
||||
Value="Significant",
|
||||
Comment="Test mapping",
|
||||
)
|
||||
],
|
||||
Checks=["iam_user_mfa_enabled_console_access"],
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
ocsf = OCSF([finding])
|
||||
technique_map = {
|
||||
"T1078": {
|
||||
"Name": "Valid Accounts",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1078/",
|
||||
"Tactics": [
|
||||
"Defense Evasion",
|
||||
"Persistence",
|
||||
"Privilege Escalation",
|
||||
"Initial Access",
|
||||
],
|
||||
},
|
||||
"T1098": {
|
||||
"Name": "Account Manipulation",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1098/",
|
||||
"Tactics": ["Persistence"],
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map",
|
||||
return_value=technique_map,
|
||||
):
|
||||
ocsf = OCSF([finding])
|
||||
output_data = ocsf.data[0]
|
||||
|
||||
assert output_data.finding_info.attacks is not None
|
||||
assert len(output_data.finding_info.attacks) == 2
|
||||
assert len(output_data.finding_info.attacks) == 5
|
||||
attack = output_data.finding_info.attacks[0]
|
||||
assert isinstance(attack, MITREAttack)
|
||||
assert attack.technique.uid == "T4242"
|
||||
assert attack.technique.name == "Synthetic Valid Accounts"
|
||||
assert attack.technique.src_url == "https://attack.mitre.org/techniques/T4242/"
|
||||
assert attack.technique.uid == "T1078"
|
||||
assert attack.technique.name == "Valid Accounts"
|
||||
assert attack.technique.src_url == "https://attack.mitre.org/techniques/T1078/"
|
||||
assert attack.tactic is not None
|
||||
assert [attack.tactic.name for attack in output_data.finding_info.attacks] == [
|
||||
"Defense Evasion",
|
||||
"Persistence",
|
||||
"Privilege Escalation",
|
||||
"Initial Access",
|
||||
"Persistence",
|
||||
]
|
||||
assert [
|
||||
attack.technique.uid for attack in output_data.finding_info.attacks
|
||||
] == [
|
||||
"T1078",
|
||||
"T1078",
|
||||
"T1078",
|
||||
"T1078",
|
||||
"T1098",
|
||||
]
|
||||
|
||||
def test_transform_mitre_attacks_unknown_technique(self):
|
||||
def test_transform_mitre_attacks_ignores_unknown_technique(self):
|
||||
finding = generate_finding_output(
|
||||
provider="aws",
|
||||
compliance={"MITRE-ATTACK": ["T9999"]},
|
||||
compliance={"MITRE-ATTACK": ["T9999", "T1098"]},
|
||||
)
|
||||
finding.metadata.Compliance = [
|
||||
Compliance(
|
||||
Framework="MITRE-ATTACK",
|
||||
Name="MITRE ATT&CK compliance framework",
|
||||
Provider="AWS",
|
||||
Version="",
|
||||
Description="MITRE ATT&CK test framework",
|
||||
Requirements=[],
|
||||
)
|
||||
]
|
||||
|
||||
ocsf = OCSF([finding])
|
||||
assert ocsf.data[0].finding_info.attacks is None
|
||||
with patch(
|
||||
"prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map",
|
||||
return_value={
|
||||
"T1098": {
|
||||
"Name": "Account Manipulation",
|
||||
"Tactics": ["Persistence"],
|
||||
}
|
||||
},
|
||||
):
|
||||
ocsf = OCSF([finding])
|
||||
attacks = ocsf.data[0].finding_info.attacks
|
||||
|
||||
def test_transform_mitre_attacks_without_mitre_metadata(self):
|
||||
assert attacks is not None
|
||||
assert len(attacks) == 1
|
||||
assert attacks[0].technique.uid == "T1098"
|
||||
|
||||
def test_transform_mitre_attacks_provider_without_catalog(self):
|
||||
finding = generate_finding_output(
|
||||
provider="kubernetes",
|
||||
compliance={"MITRE-ATTACK": ["T1078"]},
|
||||
check_type=[],
|
||||
)
|
||||
|
||||
ocsf = OCSF([finding])
|
||||
with patch(
|
||||
"prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map",
|
||||
return_value={},
|
||||
):
|
||||
ocsf = OCSF([finding])
|
||||
assert ocsf.data[0].finding_info.attacks is None
|
||||
|
||||
def test_load_mitre_technique_map_logs_failure(self):
|
||||
from prowler.lib.outputs.ocsf.ocsf import _load_mitre_technique_map
|
||||
|
||||
_load_mitre_technique_map.cache_clear()
|
||||
try:
|
||||
with (
|
||||
patch("builtins.open", side_effect=OSError("catalog unavailable")),
|
||||
patch("prowler.lib.outputs.ocsf.ocsf.logger.error") as mock_error,
|
||||
):
|
||||
assert _load_mitre_technique_map("aws") == {}
|
||||
|
||||
message = mock_error.call_args.args[0]
|
||||
assert re.fullmatch(r"OSError\[\d+\]: catalog unavailable", message)
|
||||
finally:
|
||||
_load_mitre_technique_map.cache_clear()
|
||||
|
||||
def test_transform_mitre_attacks_skips_technique_without_name(self):
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
provider="aws",
|
||||
compliance={"MITRE-ATTACK": ["T1078", "T1098"]},
|
||||
),
|
||||
generate_finding_output(provider="aws"),
|
||||
]
|
||||
technique_map = {
|
||||
"T1078": {"Tactics": ["Initial Access"]},
|
||||
"T1098": {
|
||||
"Name": "Account Manipulation",
|
||||
"Tactics": ["Persistence"],
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map",
|
||||
return_value=technique_map,
|
||||
),
|
||||
patch("prowler.lib.outputs.ocsf.ocsf.logger.warning") as mock_warning,
|
||||
):
|
||||
ocsf = OCSF(findings)
|
||||
|
||||
assert len(ocsf.data) == 2
|
||||
attacks = ocsf.data[0].finding_info.attacks
|
||||
assert attacks is not None
|
||||
assert [attack.technique.uid for attack in attacks] == ["T1098"]
|
||||
mock_warning.assert_called_once_with(
|
||||
"Skipping MITRE ATT&CK technique T1078 for provider aws: missing Name"
|
||||
)
|
||||
|
||||
def test_scan_id_is_unique_per_provider_and_account(self):
|
||||
findings = [
|
||||
generate_finding_output(provider="aws", account_uid="111111111111"),
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ ENV HOSTNAME="0.0.0.0"
|
||||
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
|
||||
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
|
||||
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
|
||||
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
|
||||
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (feedback survey)
|
||||
# - reserved: REO_DEV_CLIENT_ID (no consumer yet)
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
SingleApiKeyResponse,
|
||||
UpdateApiKeyPayload,
|
||||
} from "@/components/users/profile/api-keys/types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
import { adaptApiKeysResponse } from "./api-keys.adapter";
|
||||
|
||||
@@ -10,6 +10,9 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
AttackPathCartographySchema,
|
||||
|
||||
@@ -16,6 +16,9 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getCompliancesOverview = async ({
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
@@ -39,6 +38,10 @@ vi.mock("@/lib", () => ({
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
// Simulate real appendSanitizedProviderFilters: appends all non-undefined filters to the URL.
|
||||
appendSanitizedProviderFilters: vi.fn(
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
getAuthHeaders,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -31,10 +31,13 @@ import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock,
|
||||
}));
|
||||
|
||||
@@ -4,11 +4,8 @@ import {
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
getAuthHeaders,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
} from "@/lib";
|
||||
import { apiBaseUrl, RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("@/actions/mute-rules", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter";
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findings-triage.adapter";
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
|
||||
import { apiBaseUrl, parseStringify } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { IntegrationType } from "@/types/integrations";
|
||||
import type { TaskState } from "@/types/tasks";
|
||||
|
||||
@@ -7,7 +7,6 @@ const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
@@ -18,6 +17,10 @@ vi.mock("@/actions/task/poll", () => ({
|
||||
pollTaskUntilSettled: pollTaskUntilSettledMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
|
||||
}));
|
||||
|
||||
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
|
||||
|
||||
describe("sendJiraDispatch", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { samlConfigFormSchema } from "@/types/formSchemas";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const invitationTokenSchema = z.string().min(1).max(500);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import {
|
||||
validateBaseUrl,
|
||||
validateCredentials,
|
||||
|
||||
@@ -22,10 +22,13 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
@@ -111,6 +114,16 @@ describe("getAllProviderGroups", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows the framework redirect from authentication", async () => {
|
||||
// Given
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
getAuthHeadersMock.mockRejectedValueOnce(redirectError);
|
||||
|
||||
// When / Then
|
||||
await expect(getAllProviderGroups()).rejects.toBe(redirectError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when a later page resolves to an error payload", async () => {
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
|
||||
import { apiBaseUrl, getErrorMessage } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { ManageGroupPayload, ProviderGroupsResponse } from "@/types/components";
|
||||
|
||||
@@ -65,9 +66,9 @@ export const getAllProviderGroups = async (): Promise<
|
||||
const allGroups: ProviderGroupsResponse["data"] = [];
|
||||
let lastResponse: ProviderGroupsResponse | undefined;
|
||||
let hasMorePages = true;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
while (hasMorePages && currentPage <= maxPages) {
|
||||
const url = new URL(`${apiBaseUrl}/provider-groups`);
|
||||
url.searchParams.append("page[number]", currentPage.toString());
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
|
||||
import {
|
||||
DeleteMuteRuleActionState,
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("next/cache", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
OrganizationListResponse,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
getDateFromForTimeRange,
|
||||
type TimeRange,
|
||||
} from "@/app/(prowler)/_overview/severity-over-time/_constants/time-range.constants";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
import {
|
||||
DeleteMutedFindingsConfigActionState,
|
||||
|
||||
@@ -24,11 +24,14 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getFormValue: getFormValueMock,
|
||||
wait: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-credentials/build-credentials", () => ({
|
||||
buildSecretConfig: vi.fn(() => ({
|
||||
secretType: "access-secret-key",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib";
|
||||
import { apiBaseUrl, getFormValue, wait } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { appendSanitizedProviderInFilters } from "@/lib/provider-filters";
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
GENERIC_SERVER_ERROR_MESSAGE:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
sanitizeErrorMessage: (message: string, fallback: string) =>
|
||||
@@ -43,6 +42,10 @@ vi.mock("@/lib", () => ({
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
apiBaseUrl,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
sanitizeErrorMessage,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import yaml from "js-yaml";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
||||
import {
|
||||
DeleteScanConfigurationActionState,
|
||||
|
||||
@@ -16,11 +16,14 @@ vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
GENERIC_SERVER_ERROR_MESSAGE:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -6,9 +6,9 @@ import { redirect } from "next/navigation";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import {
|
||||
COMPLIANCE_REPORT_DISPLAY_NAMES,
|
||||
type ComplianceReportType,
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock("next/cache", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { scheduleUpdatePayloadSchema } from "@/lib/schedules";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getTask = async (taskId: string) => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signOut } from "@/auth.config";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getAllTenants = async () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
TENANT_MEMBERSHIP_ROLE,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreatScoreSSR } from "./threat-score.ssr";
|
||||
|
||||
vi.mock("@/actions/overview", () => ({
|
||||
getThreatScore: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
attributes: {
|
||||
overall_score: "72",
|
||||
score_delta: "2",
|
||||
section_scores: {},
|
||||
critical_requirements: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
LighthouseContextContributor: ({ item }: { item: unknown }) => (
|
||||
<output data-testid="overview-context">{JSON.stringify(item)}</output>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./_components/threat-score", () => ({
|
||||
ThreatScore: ({ score }: { score?: number }) => <div>Score {score}</div>,
|
||||
}));
|
||||
|
||||
describe("ThreatScoreSSR", () => {
|
||||
it("publishes the loaded overview score as Lighthouse context", async () => {
|
||||
render(await ThreatScoreSSR({ searchParams: {} }));
|
||||
|
||||
expect(screen.getByTestId("overview-context")).toHaveTextContent(
|
||||
'"score":72',
|
||||
);
|
||||
expect(screen.getByText("Score 72")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
@@ -25,11 +27,23 @@ export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ThreatScore
|
||||
score={score}
|
||||
scoreDelta={scoreDelta}
|
||||
sectionScores={attributes.section_scores}
|
||||
criticalRequirements={attributes.critical_requirements}
|
||||
/>
|
||||
<>
|
||||
<LighthouseContextContributor
|
||||
key={`overview-threat-score-${score}`}
|
||||
contributorId="overview-threat-score"
|
||||
item={buildComplianceContext({
|
||||
pathname: "/",
|
||||
id: "prowler-threat-score",
|
||||
framework: "Prowler ThreatScore",
|
||||
score,
|
||||
})}
|
||||
/>
|
||||
<ThreatScore
|
||||
score={score}
|
||||
scoreDelta={scoreDelta}
|
||||
sectionScores={attributes.section_scores}
|
||||
criticalRequirements={attributes.critical_requirements}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,11 +14,14 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
import {
|
||||
|
||||
@@ -14,11 +14,14 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const RECIPIENTS_PATH = "/alerts/recipients";
|
||||
|
||||
+51
-12
@@ -1,7 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { QueryDescription } from "./query-description";
|
||||
|
||||
@@ -23,7 +26,51 @@ const customQuery: AttackPathQuery = {
|
||||
},
|
||||
};
|
||||
|
||||
const builtInQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "aws-sts-privesc-assume-role",
|
||||
attributes: {
|
||||
name: "Role Assumption for Privilege Escalation (STS-001)",
|
||||
short_description: "Detect principals who can assume other IAM roles.",
|
||||
description:
|
||||
"Detect principals who can assume other IAM roles via sts:AssumeRole.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("QueryDescription", () => {
|
||||
it("renders a Prowler Hub link for a built-in query", () => {
|
||||
// Given
|
||||
render(<QueryDescription query={builtInQuery} />);
|
||||
|
||||
// When
|
||||
const link = screen.getByRole("link", { name: /view on prowler hub/i });
|
||||
|
||||
// Then
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://hub.prowler.com/attack-paths/aws-sts-privesc-assume-role",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not render a Prowler Hub link for the custom query", () => {
|
||||
// Given
|
||||
const query: AttackPathQuery = {
|
||||
...customQuery,
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
};
|
||||
|
||||
// When
|
||||
render(<QueryDescription query={query} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /view on prowler hub/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the documentation link inside an info alert", () => {
|
||||
// Given
|
||||
render(<QueryDescription query={customQuery} />);
|
||||
@@ -39,9 +86,9 @@ describe("QueryDescription", () => {
|
||||
expect(link).toHaveAttribute("href", "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
|
||||
it("does not render an unsafe documentation URL as a clickable link", () => {
|
||||
// Given
|
||||
const queryWithUnsafeLinks: AttackPathQuery = {
|
||||
const queryWithUnsafeLink: AttackPathQuery = {
|
||||
...customQuery,
|
||||
attributes: {
|
||||
...customQuery.attributes,
|
||||
@@ -49,15 +96,11 @@ describe("QueryDescription", () => {
|
||||
text: "Learn how to write custom openCypher queries",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
attribution: {
|
||||
text: "Unsafe source",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
render(<QueryDescription query={queryWithUnsafeLinks} />);
|
||||
render(<QueryDescription query={queryWithUnsafeLink} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
@@ -65,12 +108,8 @@ describe("QueryDescription", () => {
|
||||
name: /learn how to write custom opencypher queries/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /unsafe source/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/learn how to write custom opencypher queries/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+24
-24
@@ -1,7 +1,11 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import { getAttackPathHubUrl } from "@/lib/external-urls";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
interface QueryDescriptionProps {
|
||||
query: AttackPathQuery;
|
||||
@@ -18,7 +22,12 @@ const isSafeUrl = (url: string): boolean => {
|
||||
|
||||
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||
const documentationLink = query.attributes.documentation_link;
|
||||
const attribution = query.attributes.attribution;
|
||||
// Every built-in query has a Prowler Hub page keyed by its id. The synthetic
|
||||
// custom query has no catalog entry, so it gets no hub link.
|
||||
const hubUrl =
|
||||
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM
|
||||
? null
|
||||
: getAttackPathHubUrl(query.id);
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
@@ -26,6 +35,19 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||
<AlertDescription className="w-full gap-2">
|
||||
<p className="whitespace-pre-line">{query.attributes.description}</p>
|
||||
|
||||
{hubUrl && (
|
||||
<p className="text-xs">
|
||||
<a
|
||||
href={hubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
View on Prowler Hub
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{documentationLink && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(documentationLink.link) ? (
|
||||
@@ -42,28 +64,6 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{attribution && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(attribution.link) ? (
|
||||
<>
|
||||
Source:{" "}
|
||||
<a
|
||||
href={attribution.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{attribution.text}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Source: <span>{attribution.text}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { create } from "zustand";
|
||||
|
||||
import type {
|
||||
AttackPathGraphData,
|
||||
AttackPathQueryExecution,
|
||||
GraphNode,
|
||||
GraphState,
|
||||
} from "@/types/attack-paths";
|
||||
@@ -22,7 +23,10 @@ interface FilteredViewState {
|
||||
}
|
||||
|
||||
interface GraphStore extends GraphState, FilteredViewState {
|
||||
setGraphData: (data: AttackPathGraphData) => void;
|
||||
setGraphData: (
|
||||
data: AttackPathGraphData,
|
||||
execution: AttackPathQueryExecution | null,
|
||||
) => void;
|
||||
setSelectedNodeId: (nodeId: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
@@ -38,6 +42,7 @@ interface GraphStore extends GraphState, FilteredViewState {
|
||||
|
||||
const initialState: GraphState & FilteredViewState = {
|
||||
data: null,
|
||||
execution: null,
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -49,9 +54,10 @@ const initialState: GraphState & FilteredViewState = {
|
||||
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
...initialState,
|
||||
setGraphData: (data) =>
|
||||
setGraphData: (data, execution) =>
|
||||
set({
|
||||
data,
|
||||
execution,
|
||||
fullData: null,
|
||||
error: null,
|
||||
isFilteredView: false,
|
||||
@@ -88,8 +94,11 @@ export const useGraphState = () => {
|
||||
const store = useGraphStore();
|
||||
|
||||
// Zustand store methods are stable, no need to memoize
|
||||
const updateGraphData = (data: AttackPathGraphData) => {
|
||||
store.setGraphData(data);
|
||||
const updateGraphData = (
|
||||
data: AttackPathGraphData,
|
||||
execution: AttackPathQueryExecution,
|
||||
) => {
|
||||
store.setGraphData(data, execution);
|
||||
};
|
||||
|
||||
const selectNode = (nodeId: string | null) => {
|
||||
@@ -120,7 +129,7 @@ export const useGraphState = () => {
|
||||
};
|
||||
|
||||
const clearGraph = () => {
|
||||
store.setGraphData({ nodes: [], edges: [] });
|
||||
store.setGraphData({ nodes: [], edges: [] }, null);
|
||||
store.setSelectedNodeId(null);
|
||||
store.setFilteredView(false, null, null, null);
|
||||
};
|
||||
@@ -161,6 +170,7 @@ export const useGraphState = () => {
|
||||
|
||||
return {
|
||||
data: store.data,
|
||||
execution: store.execution,
|
||||
fullData: store.fullData,
|
||||
selectedNodeId: store.selectedNodeId,
|
||||
selectedNode: getSelectedNode(),
|
||||
|
||||
+67
-29
@@ -15,6 +15,8 @@ import { beforeEach, describe, expect, test as base, vi } from "vitest";
|
||||
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
|
||||
const { getFindingByIdMock } = vi.hoisted(() => ({
|
||||
getFindingByIdMock: vi.fn(),
|
||||
@@ -50,6 +52,7 @@ interface Fixtures {
|
||||
// one (selection, filtered view, expanded resources, etc.).
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
resetLighthouseContextStore();
|
||||
getFindingByIdMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -101,17 +104,6 @@ describe("waiting states", () => {
|
||||
});
|
||||
|
||||
describe("running a query", () => {
|
||||
test("the query builder surface uses the shared card primitive", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
|
||||
const card = await graph.waitFor(() => graph.queryBuilderCard, 10000);
|
||||
|
||||
expect(card).toHaveAttribute("data-slot", "card");
|
||||
expect(card).toHaveClass("rounded-xl");
|
||||
});
|
||||
|
||||
test("a parameterized query shows its required inputs after selection", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
@@ -126,16 +118,75 @@ describe("running a query", () => {
|
||||
expect(graph.getInputByName("tag_value")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the graph renders with a background, a minimap, and a viewport", async ({
|
||||
test("changing the form keeps Lighthouse bound to the query that produced the graph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
// Given
|
||||
const fixture = fixtures.typical();
|
||||
const graph = await mountWith(fixture);
|
||||
await graph.executeQuery();
|
||||
|
||||
// When
|
||||
await graph.selectQuery("aws-open-security-groups");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toMatchObject({
|
||||
queryId: fixture.queryId,
|
||||
queryKind: "predefined",
|
||||
canReplayQuery: true,
|
||||
label: "Public S3 buckets",
|
||||
nodeCount: fixture.queryResult?.nodes.length,
|
||||
edgeCount: fixture.queryResult?.relationships?.length,
|
||||
});
|
||||
});
|
||||
|
||||
test("editing parameters keeps Lighthouse bound to the executed values", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
// Given
|
||||
const graph = await mountWith(fixtures.parameterizedQuery());
|
||||
await graph.selectQuery();
|
||||
await graph.fillInput("tag_key", "DataClassification");
|
||||
await graph.fillInput("tag_value", "Sensitive");
|
||||
await graph.executeQuery({ selectFirst: false });
|
||||
|
||||
// When
|
||||
await graph.fillInput("tag_value", "Confidential");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toMatchObject({
|
||||
parameters: {
|
||||
tag_key: "DataClassification",
|
||||
tag_value: "Sensitive",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("loading another execution removes stale graph context", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
// Given
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForGraphStable(3);
|
||||
|
||||
expect(graph.background).toBeTruthy();
|
||||
expect(graph.minimap).toBeTruthy();
|
||||
expect(graph.viewport).toBeTruthy();
|
||||
// When
|
||||
useGraphStore.getState().setLoading(true);
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions[
|
||||
"attack-path-current"
|
||||
],
|
||||
).toMatchObject({
|
||||
id: "current-scan",
|
||||
queryId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("nodes are laid out at distinct positions", async ({ mountWith }) => {
|
||||
@@ -147,19 +198,6 @@ describe("running a query", () => {
|
||||
expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("the toolbar exposes zoom, fit, and export controls", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForGraphStable(1);
|
||||
|
||||
expect(graph.toolbar.zoomInButton).toBeTruthy();
|
||||
expect(graph.toolbar.zoomOutButton).toBeTruthy();
|
||||
expect(graph.toolbar.fitButton).toBeTruthy();
|
||||
expect(graph.toolbar.exportButton).toBeTruthy();
|
||||
});
|
||||
|
||||
test("finding, resource, and internet nodes all render", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
|
||||
@@ -233,6 +233,12 @@ export class AttackPathPageHarness {
|
||||
);
|
||||
}
|
||||
|
||||
async fillInput(name: string, value: string): Promise<void> {
|
||||
const input = this.getInputByName(name);
|
||||
if (!input) throw new Error(`fillInput: input "${name}" not found`);
|
||||
await this.user.fill(input, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline `transform` of the React Flow viewport element. This is the
|
||||
* pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { PageReady } from "@/components/onboarding";
|
||||
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
|
||||
import {
|
||||
@@ -41,7 +43,11 @@ import type {
|
||||
AttackPathQueryError,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
ATTACK_PATH_QUERY_KIND,
|
||||
SCAN_STATES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
AttackPathGraph,
|
||||
@@ -258,12 +264,14 @@ export default function AttackPathsPage() {
|
||||
graphState.setError(null);
|
||||
|
||||
try {
|
||||
const parameters = queryBuilder.getQueryParameters();
|
||||
const isCustomQuery =
|
||||
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||
const queryId = queryBuilder.selectedQuery;
|
||||
const queryLabel =
|
||||
queryBuilder.selectedQueryData?.attributes.name ?? queryId;
|
||||
const parameters = { ...queryBuilder.getQueryParameters() };
|
||||
const isCustomQuery = queryId === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||
const result = isCustomQuery
|
||||
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
|
||||
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
|
||||
: await executeQuery(scanId, queryId, parameters);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as AttackPathQueryError;
|
||||
@@ -289,7 +297,14 @@ export default function AttackPathsPage() {
|
||||
}
|
||||
} else if (result?.data?.attributes) {
|
||||
const graphData = adaptQueryResultToGraphData(result.data.attributes);
|
||||
graphState.updateGraphData(graphData);
|
||||
graphState.updateGraphData(graphData, {
|
||||
queryId,
|
||||
queryLabel,
|
||||
queryKind: isCustomQuery
|
||||
? ATTACK_PATH_QUERY_KIND.CUSTOM
|
||||
: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters,
|
||||
});
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Query executed successfully",
|
||||
@@ -396,6 +411,29 @@ export default function AttackPathsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const lighthouseSelectedNode =
|
||||
graphState.selectedNode ?? graphState.filteredNode;
|
||||
const lighthouseGraphData = graphState.fullData ?? graphState.data;
|
||||
const lighthouseExecution = graphState.loading ? null : graphState.execution;
|
||||
const lighthouseContext = scanId
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<AutoRefresh
|
||||
@@ -410,6 +448,14 @@ export default function AttackPathsPage() {
|
||||
{/* Enables the navbar replay icon once the initial scan load resolves. */}
|
||||
{!scansLoading && <PageReady />}
|
||||
|
||||
{lighthouseContext && (
|
||||
<LighthouseContextContributor
|
||||
key={JSON.stringify(lighthouseContext)}
|
||||
contributorId="attack-path-current"
|
||||
item={lighthouseContext}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div data-tour-id="attack-paths-intro">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Select a scan, build a query, and visualize Attack Paths in your
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TopFailedSectionsCardSkeleton,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
getReportTypeForCompliance,
|
||||
pickLatestCisPerProvider,
|
||||
} from "@/lib/compliance/compliance-report-types";
|
||||
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchParamsProps } from "@/types";
|
||||
@@ -77,7 +80,7 @@ export default async function ComplianceDetail({
|
||||
// Cross-provider mode replaces the per-scan pipeline with the universal
|
||||
// roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so
|
||||
// the route is blocked in OSS the same way the compliance tab is.
|
||||
if (mode === "cross-provider") {
|
||||
if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_PROVIDER) {
|
||||
if (!isCloud()) {
|
||||
redirect("/compliance");
|
||||
}
|
||||
@@ -117,7 +120,7 @@ export default async function ComplianceDetail({
|
||||
}
|
||||
// Cross-account mode: one regular framework aggregated across every
|
||||
// account of one provider type. Cloud-only, like cross-provider.
|
||||
if (mode === "cross-account") {
|
||||
if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_ACCOUNT) {
|
||||
if (!isCloud()) {
|
||||
redirect("/compliance");
|
||||
}
|
||||
@@ -342,7 +345,10 @@ export default async function ComplianceDetail({
|
||||
>
|
||||
<SSRComplianceContent
|
||||
complianceId={complianceId}
|
||||
pathname={`/compliance/${compliancetitle}`}
|
||||
scanId={selectedScanId || ""}
|
||||
providerUid={selectedScan?.providerInfo.uid}
|
||||
mode={LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.PER_SCAN}
|
||||
region={regionFilter}
|
||||
filter={cisProfileFilter}
|
||||
attributesData={attributesData}
|
||||
@@ -356,7 +362,10 @@ export default async function ComplianceDetail({
|
||||
|
||||
const SSRComplianceContent = async ({
|
||||
complianceId,
|
||||
pathname,
|
||||
scanId,
|
||||
providerUid,
|
||||
mode,
|
||||
region,
|
||||
filter,
|
||||
attributesData,
|
||||
@@ -364,7 +373,10 @@ const SSRComplianceContent = async ({
|
||||
targetSection,
|
||||
}: {
|
||||
complianceId: string;
|
||||
pathname: string;
|
||||
scanId: string;
|
||||
providerUid?: string;
|
||||
mode: typeof LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.PER_SCAN;
|
||||
region?: string;
|
||||
filter?: string;
|
||||
attributesData: AttributesData;
|
||||
@@ -412,6 +424,7 @@ const SSRComplianceContent = async ({
|
||||
);
|
||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
const frameworkAttributes = attributesData?.data?.[0]?.attributes;
|
||||
|
||||
// Resolve which accordion key matches the requested ?section= so we can
|
||||
// auto-expand it on first render. Each mapper builds keys as
|
||||
@@ -430,6 +443,31 @@ const SSRComplianceContent = async ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<LighthouseContextContributor
|
||||
key={`compliance-detail-${complianceId}-${totalRequirements.pass}-${totalRequirements.fail}`}
|
||||
contributorId="compliance-detail"
|
||||
item={buildComplianceContext({
|
||||
pathname,
|
||||
id: complianceId,
|
||||
framework:
|
||||
frameworkAttributes?.name ||
|
||||
frameworkAttributes?.framework ||
|
||||
complianceId,
|
||||
version: frameworkAttributes?.version,
|
||||
scanId,
|
||||
providerUid,
|
||||
mode,
|
||||
section: targetSection,
|
||||
region,
|
||||
score: threatScoreData?.overallScore,
|
||||
passed: totalRequirements.pass,
|
||||
failed: totalRequirements.fail,
|
||||
total:
|
||||
totalRequirements.pass +
|
||||
totalRequirements.fail +
|
||||
totalRequirements.manual,
|
||||
})}
|
||||
/>
|
||||
{/* Charts section */}
|
||||
{/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
|
||||
<div
|
||||
|
||||
@@ -15,11 +15,14 @@ const {
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -14,12 +14,15 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getAllProviderGroupsMock,
|
||||
getAllProvidersMock,
|
||||
getCrossAccountComplianceOverviewMock,
|
||||
getLatestCrossAccountPdfMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllProviderGroupsMock: vi.fn(),
|
||||
getAllProvidersMock: vi.fn(),
|
||||
getCrossAccountComplianceOverviewMock: vi.fn(),
|
||||
getLatestCrossAccountPdfMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/manage-groups/manage-groups", () => ({
|
||||
getAllProviderGroups: getAllProviderGroupsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => ({
|
||||
getAllProviders: getAllProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/compliance/IconCompliance", () => ({
|
||||
getComplianceIcon: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
LighthouseContextContributor: ({ item }: { item: unknown }) => (
|
||||
<output data-testid="lighthouse-context">{JSON.stringify(item)}</output>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/compliance/compliance-mapper", () => ({
|
||||
getComplianceMapper: () => ({
|
||||
mapComplianceData: () => [],
|
||||
getTopFailedSections: () => ({
|
||||
items: [],
|
||||
type: "requirements",
|
||||
prepopulated: false,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../_actions/cross-account", () => ({
|
||||
getCrossAccountComplianceOverview: getCrossAccountComplianceOverviewMock,
|
||||
getLatestCrossAccountPdf: getLatestCrossAccountPdfMock,
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/aggregated-compliance-detail", () => ({
|
||||
getAggregatedInitialExpandedKeys: () => [],
|
||||
getAggregatedRequirementsTotals: () => ({
|
||||
pass: 8,
|
||||
fail: 2,
|
||||
manual: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/cross-account-accordion", () => ({
|
||||
toCrossAccountAccordionItems: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/cross-account-adapter", () => ({
|
||||
buildAccountExtrasMap: () => new Map(),
|
||||
computeAccountBreakdown: () => [],
|
||||
crossAccountToMapperInput: () => ({
|
||||
attributesData: {},
|
||||
requirementsData: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/cross-account-frameworks", () => ({
|
||||
parseCrossAccountFilters: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("./aggregated-compliance-detail", () => ({
|
||||
AggregatedComplianceDetail: () => (
|
||||
<div data-testid="aggregated-compliance-detail" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./cross-provider-error-alert", () => ({
|
||||
CrossProviderErrorAlert: () => <div data-testid="cross-provider-error" />,
|
||||
}));
|
||||
|
||||
vi.mock("./cross-provider-filters", () => ({
|
||||
CrossProviderFilters: () => <div data-testid="cross-provider-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("./cross-provider-pdf-button", () => ({
|
||||
CrossProviderPdfButton: () => <div data-testid="cross-provider-pdf" />,
|
||||
}));
|
||||
|
||||
vi.mock("./provider-coverage-card", () => ({
|
||||
ProviderCoverageCard: () => <div data-testid="provider-coverage" />,
|
||||
}));
|
||||
|
||||
import { CrossAccountDetail } from "./cross-account-detail";
|
||||
|
||||
describe("CrossAccountDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAllProvidersMock.mockResolvedValue({ data: [] });
|
||||
getAllProviderGroupsMock.mockResolvedValue({ data: [] });
|
||||
getLatestCrossAccountPdfMock.mockResolvedValue(null);
|
||||
getCrossAccountComplianceOverviewMock.mockResolvedValue({
|
||||
status: "success",
|
||||
response: {
|
||||
data: {
|
||||
attributes: {
|
||||
accounts: [],
|
||||
framework: "CIS",
|
||||
name: "CIS AWS Foundations",
|
||||
scan_ids: ["scan-1"],
|
||||
version: "2.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes cross-account compliance context", async () => {
|
||||
// Given / When
|
||||
render(
|
||||
await CrossAccountDetail({
|
||||
compliancetitle: "cis-aws-foundations",
|
||||
complianceId: "cis_aws_2.0",
|
||||
providerType: "aws",
|
||||
searchParams: {},
|
||||
targetSection: "IAM",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("aggregated-compliance-detail")).toBeVisible();
|
||||
expect(screen.getByTestId("lighthouse-context")).toHaveTextContent(
|
||||
'"mode":"cross-account"',
|
||||
);
|
||||
expect(screen.getByTestId("lighthouse-context")).toHaveTextContent(
|
||||
'"section":"IAM"',
|
||||
);
|
||||
expect(screen.getByTestId("lighthouse-context")).toHaveTextContent(
|
||||
'"totals":{"passed":8,"failed":2,"total":11}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,11 @@ import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
@@ -163,52 +166,69 @@ export const CrossAccountDetail = async ({
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
return (
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={14} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "}
|
||||
{attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "}
|
||||
{attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
|
||||
</p>
|
||||
}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={
|
||||
<ProviderCoverageCard
|
||||
rows={coverageRows}
|
||||
title="Account Coverage"
|
||||
emptyMessage="No scanned accounts for this framework yet."
|
||||
/>
|
||||
}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
<>
|
||||
<LighthouseContextContributor
|
||||
key={`cross-account-detail-${complianceId}-${totals.pass}-${totals.fail}`}
|
||||
contributorId="compliance-detail"
|
||||
item={buildComplianceContext({
|
||||
pathname: `/compliance/${compliancetitle}`,
|
||||
id: complianceId,
|
||||
framework: attrs.name || attrs.framework,
|
||||
version: attrs.version,
|
||||
mode: LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_ACCOUNT,
|
||||
section: targetSection,
|
||||
passed: totals.pass,
|
||||
failed: totals.fail,
|
||||
total: totals.pass + totals.fail + totals.manual,
|
||||
})}
|
||||
/>
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={14} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "}
|
||||
{attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "}
|
||||
{attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
|
||||
</p>
|
||||
}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={
|
||||
<ProviderCoverageCard
|
||||
rows={coverageRows}
|
||||
title="Account Coverage"
|
||||
emptyMessage="No scanned accounts for this framework yet."
|
||||
/>
|
||||
}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,11 @@ import { Info } from "lucide-react";
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
|
||||
import {
|
||||
getCrossProviderComplianceOverview,
|
||||
@@ -152,45 +155,62 @@ export const CrossProviderDetail = async ({
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
return (
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{attrs.providers.length} of {compatibleTypes.length} compatible
|
||||
providers scanned · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated
|
||||
</p>
|
||||
}
|
||||
headerLink={<CrossProviderHubLink complianceId={complianceId} />}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerTypes={compatibleTypes}
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={<ProviderCoverageCard breakdown={providerBreakdown} />}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
<>
|
||||
<LighthouseContextContributor
|
||||
key={`cross-provider-detail-${complianceId}-${totals.pass}-${totals.fail}`}
|
||||
contributorId="compliance-detail"
|
||||
item={buildComplianceContext({
|
||||
pathname: `/compliance/${compliancetitle}`,
|
||||
id: complianceId,
|
||||
framework: attrs.name || attrs.framework,
|
||||
version: attrs.version,
|
||||
mode: LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_PROVIDER,
|
||||
section: targetSection,
|
||||
passed: totals.pass,
|
||||
failed: totals.fail,
|
||||
total: totals.pass + totals.fail + totals.manual,
|
||||
})}
|
||||
/>
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{attrs.providers.length} of {compatibleTypes.length} compatible
|
||||
providers scanned · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated
|
||||
</p>
|
||||
}
|
||||
headerLink={<CrossProviderHubLink complianceId={complianceId} />}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerTypes={compatibleTypes}
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={<ProviderCoverageCard breakdown={providerBreakdown} />}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlertTriangle, Info } from "lucide-react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import {
|
||||
Section,
|
||||
@@ -10,6 +11,11 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import {
|
||||
LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE,
|
||||
LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT,
|
||||
} from "@/lib/lighthouse/context/constants";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { KnownProviderType } from "@/types/providers";
|
||||
|
||||
@@ -158,6 +164,24 @@ export const CrossProviderOverview = async ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{summaries
|
||||
.slice(0, LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT.AFTER_PAGE)
|
||||
.map((summary) => (
|
||||
<LighthouseContextContributor
|
||||
key={`cross-provider-${summary.complianceId}-${summary.requirementsPassed}-${summary.requirementsFailed}`}
|
||||
contributorId={`cross-provider-${summary.complianceId}`}
|
||||
item={buildComplianceContext({
|
||||
pathname: "/compliance",
|
||||
id: summary.complianceId,
|
||||
framework: summary.title,
|
||||
version: summary.version,
|
||||
mode: LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_PROVIDER,
|
||||
passed: summary.requirementsPassed,
|
||||
failed: summary.requirementsFailed,
|
||||
total: summary.totalRequirements,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<CrossProviderFilters
|
||||
providerTypes={compatibleTypes}
|
||||
providerAccounts={providerAccounts}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { GENERIC_SERVER_ERROR_MESSAGE, getErrorMessage } from "@/lib";
|
||||
import { hasActionError, type ActionErrorResult } from "@/lib/action-errors";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NavigationProgress } from "@/components/shadcn/navigation-progress";
|
||||
import { Toaster } from "@/components/shadcn/toast";
|
||||
import { TaskPollingWatcher } from "@/components/shared/task-polling-watcher";
|
||||
import { GlobalSidePanel } from "@/components/side-panel";
|
||||
import { FeedbackSurvey } from "@/components/survey/feedback-survey";
|
||||
import { fontMono, fontSans } from "@/config/fonts";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -52,8 +53,8 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Onboarding is Cloud-only; skip its fetches and orchestrators in OSS.
|
||||
const onboardingEnabled = isCloud();
|
||||
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
|
||||
const cloudEnabled = isCloud();
|
||||
|
||||
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
|
||||
// progression on a fetch error.
|
||||
@@ -61,7 +62,7 @@ export default async function RootLayout({
|
||||
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
|
||||
let hasProviders: boolean | undefined = false;
|
||||
|
||||
if (onboardingEnabled) {
|
||||
if (cloudEnabled) {
|
||||
const [providersData, scansByState] = await Promise.all([
|
||||
getProviders({ page: 1, pageSize: 1 }),
|
||||
getScansByState(),
|
||||
@@ -98,7 +99,7 @@ export default async function RootLayout({
|
||||
</Suspense>
|
||||
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
|
||||
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
|
||||
{onboardingEnabled && (
|
||||
{cloudEnabled && (
|
||||
<>
|
||||
<OnboardingGate hasProviders={hasProviders} />
|
||||
{/* Single mount point so the watcher survives post-connect navigation. */}
|
||||
@@ -108,6 +109,7 @@ export default async function RootLayout({
|
||||
</>
|
||||
)}
|
||||
<MainLayout>{children}</MainLayout>
|
||||
{cloudEnabled && <FeedbackSurvey />}
|
||||
{/* Always mounted: it hosts the detail (finding/resource) views in
|
||||
every deployment; the AI tab inside is cloud-gated on its own. */}
|
||||
<GlobalSidePanel />
|
||||
|
||||
@@ -15,15 +15,18 @@ vi.mock("@sentry/nextjs", () => ({
|
||||
// keeping the revalidate gate (the behavior under test) running for real.
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: "Bearer token-123" }),
|
||||
parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)),
|
||||
getErrorMessage: (error: unknown) => String(error),
|
||||
sanitizeErrorMessage: (message: string) => message,
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Server error",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: "Bearer token-123" }),
|
||||
}));
|
||||
|
||||
import {
|
||||
createLighthouseV2Session,
|
||||
getLighthouseV2SupportedModels,
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
LighthouseV2SupportedModel,
|
||||
LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type { JsonApiDocument } from "@/types/jsonapi";
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ChatComposerPanelProps {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
contextControl?: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
@@ -86,6 +87,7 @@ interface ChatComposerProps {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
contextControl?: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
@@ -99,6 +101,7 @@ function ChatComposer({
|
||||
selectedConfigurationConnected,
|
||||
onInputChange,
|
||||
modelSelector,
|
||||
contextControl,
|
||||
onSubmit,
|
||||
onSubmitText,
|
||||
}: ChatComposerProps) {
|
||||
@@ -153,6 +156,7 @@ function ChatComposer({
|
||||
</Link>
|
||||
</Button>
|
||||
{modelSelector}
|
||||
{contextControl}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<div
|
||||
|
||||
@@ -42,10 +42,12 @@ interface ChatEmptyStateProps {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
contextControl?: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
onSubmitText: (text: string) => Promise<void>;
|
||||
suggestions?: readonly string[];
|
||||
footer?: ReactNode;
|
||||
// Side-panel variant: smaller logo and static (non-animated) copy — the
|
||||
// decrypt animation reflows multi-line text in narrow widths.
|
||||
@@ -54,6 +56,7 @@ interface ChatEmptyStateProps {
|
||||
|
||||
export function ChatEmptyState({
|
||||
onInputChange,
|
||||
suggestions,
|
||||
footer,
|
||||
compact = false,
|
||||
...composerPanelProps
|
||||
@@ -110,21 +113,33 @@ export function ChatEmptyState({
|
||||
<span className="text-text-neutral-secondary basis-full text-center text-sm font-medium">
|
||||
Try Lighthouse AI for...
|
||||
</span>
|
||||
{LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
|
||||
const Icon = suggestion.icon;
|
||||
return (
|
||||
<Button
|
||||
key={suggestion.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onInputChange(suggestion.prompt)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{suggestion.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{suggestions
|
||||
? suggestions.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onInputChange(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))
|
||||
: LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
|
||||
const Icon = suggestion.icon;
|
||||
return (
|
||||
<Button
|
||||
key={suggestion.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onInputChange(suggestion.prompt)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{suggestion.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{footer ? <div className="w-full max-w-4xl">{footer}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,11 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
updateLighthouseV2Configuration: updateConfigurationMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => window.location.pathname,
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}));
|
||||
|
||||
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
|
||||
// jsdom; render its text passthrough so message bodies are still assertable.
|
||||
vi.mock("streamdown", () => ({
|
||||
@@ -109,6 +114,7 @@ describe("LighthouseV2ChatPage", () => {
|
||||
updateConfigurationMock.mockReset();
|
||||
resetPanelChatStoreForTests();
|
||||
eventSources = stubEventSource();
|
||||
window.history.replaceState(null, "", "/lighthouse");
|
||||
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: {
|
||||
@@ -136,27 +142,6 @@ describe("LighthouseV2ChatPage", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the searchable model selector and settings shortcut", () => {
|
||||
// Given / When
|
||||
renderPage();
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Lighthouse AI settings" }),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
});
|
||||
|
||||
it("renders the empty-state headline with correct wording", () => {
|
||||
// Given / When
|
||||
renderPage();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText("Find and remediate what actually matters."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues using the panel chat store on the full-page surface", () => {
|
||||
// Given: the panel owns an in-progress new chat with a draft
|
||||
const panelStore = getOrCreatePanelChatStore({
|
||||
@@ -365,45 +350,6 @@ describe("LighthouseV2ChatPage", () => {
|
||||
expect(screen.queryByText("Amazon Bedrock")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the tuned scrollbar and bottom fade without a composer separator", () => {
|
||||
// Given / When
|
||||
const { container } = renderPage({
|
||||
initialMessages: [message("message-1", "assistant", "Existing answer")],
|
||||
});
|
||||
|
||||
// Then
|
||||
const conversation = screen.getByRole("log");
|
||||
const scrollViewport = conversation.firstElementChild as HTMLElement;
|
||||
const content = scrollViewport.firstElementChild as HTMLElement;
|
||||
const scrollFade = container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-scroll-fade"]',
|
||||
);
|
||||
|
||||
expect(conversation).toHaveClass("h-full", "min-h-0");
|
||||
expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden");
|
||||
expect(scrollViewport).toHaveClass(
|
||||
"minimal-scrollbar",
|
||||
"overflow-x-hidden",
|
||||
"overflow-y-auto",
|
||||
);
|
||||
expect(content).toHaveClass("pb-20");
|
||||
expect(scrollFade).toHaveClass(
|
||||
"pointer-events-none",
|
||||
"absolute",
|
||||
"bottom-0",
|
||||
"right-2",
|
||||
"h-16",
|
||||
"bg-gradient-to-t",
|
||||
"from-bg-neutral-secondary",
|
||||
"to-transparent",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-composer-panel"]',
|
||||
),
|
||||
).not.toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("opens the highest-priority connected provider with its remembered model", async () => {
|
||||
// Given: both OpenAI and Bedrock are connected; OpenAI outranks Bedrock
|
||||
const user = userEvent.setup();
|
||||
@@ -495,25 +441,6 @@ describe("LighthouseV2ChatPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the selected chat model as that provider's default", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "anthropic.claude-4" }),
|
||||
);
|
||||
|
||||
// Then: only the chosen provider's config is updated, by id
|
||||
await waitFor(() =>
|
||||
expect(updateConfigurationMock).toHaveBeenCalledWith("config-bedrock", {
|
||||
defaultModel: "anthropic.claude-4",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chosen model applied and surfaces the backend reason when saving the default fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip";
|
||||
import { Card } from "@/components/shadcn";
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxGroup,
|
||||
} from "@/components/shadcn/combobox/combobox";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context";
|
||||
|
||||
import { ProviderIcon } from "../config/provider-icon";
|
||||
|
||||
@@ -53,6 +55,7 @@ export function LighthouseV2ChatView({
|
||||
surface,
|
||||
emptyStateFooter,
|
||||
}: LighthouseV2ChatViewProps) {
|
||||
const currentContext = useLighthouseCurrentContext();
|
||||
// Whole-store subscription is intentional: the view renders most of the state and selectLighthouseChatCanSend takes full state.
|
||||
const state = useLighthouseChatStore((current) => current);
|
||||
const {
|
||||
@@ -62,13 +65,14 @@ export function LighthouseV2ChatView({
|
||||
input,
|
||||
feedback,
|
||||
isLoadingSession,
|
||||
lastSubmittedText,
|
||||
lastSubmission,
|
||||
selectedModelSelection,
|
||||
modelPreferenceSaving,
|
||||
setInput,
|
||||
dismissFeedback,
|
||||
selectModel,
|
||||
submitMessage,
|
||||
retryLastMessage,
|
||||
} = state;
|
||||
const { modelsByProvider, supportedProviders } = config;
|
||||
const connectedConfigurations = config.configurations.filter(
|
||||
@@ -109,6 +113,10 @@ export function LighthouseV2ChatView({
|
||||
: "";
|
||||
|
||||
const canSend = selectLighthouseChatCanSend(state);
|
||||
const supportsAutomaticContext = surface === LIGHTHOUSE_CHAT_SURFACE.PANEL;
|
||||
const messageContext = supportsAutomaticContext
|
||||
? currentContext.context
|
||||
: undefined;
|
||||
|
||||
const handleModelValueChange = (value: string) => {
|
||||
const selection = parseLighthouseV2ModelSelectionValue(value);
|
||||
@@ -118,7 +126,7 @@ export function LighthouseV2ChatView({
|
||||
|
||||
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void submitMessage(input);
|
||||
void submitMessage(input, messageContext);
|
||||
};
|
||||
|
||||
const hasLiveAssistantActivity =
|
||||
@@ -131,10 +139,12 @@ export function LighthouseV2ChatView({
|
||||
feedback,
|
||||
canRetry:
|
||||
streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED &&
|
||||
lastSubmittedText !== null,
|
||||
onRetry: () =>
|
||||
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
|
||||
lastSubmission !== null,
|
||||
onRetry: () => void retryLastMessage(),
|
||||
onDismissFeedback: dismissFeedback,
|
||||
contextControl: supportsAutomaticContext ? (
|
||||
<LighthouseCurrentContextBadge context={currentContext.context} />
|
||||
) : undefined,
|
||||
canSend,
|
||||
input,
|
||||
isStreaming: Boolean(streamState.activeTaskId),
|
||||
@@ -162,7 +172,7 @@ export function LighthouseV2ChatView({
|
||||
selectedConfigurationConnected: selectedConfiguration?.connected === true,
|
||||
onInputChange: setInput,
|
||||
onSubmit: handleSubmit,
|
||||
onSubmitText: submitMessage,
|
||||
onSubmitText: (text: string) => submitMessage(text, messageContext),
|
||||
};
|
||||
|
||||
const chatBody = isLoadingSession ? (
|
||||
@@ -203,6 +213,9 @@ export function LighthouseV2ChatView({
|
||||
{...composerPanelProps}
|
||||
footer={emptyStateFooter}
|
||||
compact={surface === LIGHTHOUSE_CHAT_SURFACE.PANEL}
|
||||
suggestions={
|
||||
supportsAutomaticContext ? currentContext.page.suggestions : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import {
|
||||
LIGHTHOUSE_V2_MESSAGE_ROLE,
|
||||
LIGHTHOUSE_V2_PART_TYPE,
|
||||
type LighthouseV2Message,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("should wrap long user text inside the message bubble", async () => {
|
||||
// Given
|
||||
const longText = "a".repeat(500);
|
||||
const userMessage: LighthouseV2Message = {
|
||||
id: "message-user-long-text",
|
||||
role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [
|
||||
{
|
||||
id: "part-user-long-text",
|
||||
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
|
||||
content: { text: longText },
|
||||
toolCallOutcome: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
updatedAt: "2026-06-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const { getByText } = await render(
|
||||
<div style={{ width: 320 }}>
|
||||
<MessageBubble message={userMessage} />
|
||||
</div>,
|
||||
);
|
||||
const messageText = getByText(longText).element();
|
||||
|
||||
// Then
|
||||
expect(messageText.scrollWidth).toBeLessThanOrEqual(
|
||||
messageText.clientWidth,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,93 @@ vi.mock("streamdown", () => ({
|
||||
}));
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("should never render the agent-facing context block for user messages", () => {
|
||||
// Given
|
||||
const userMessage: LighthouseV2Message = {
|
||||
id: "message-user-1",
|
||||
role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [
|
||||
{
|
||||
id: "part-user-1",
|
||||
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
|
||||
content: {
|
||||
text: "[PROWLER_UI_CONTEXT_V1]\nmetadata\n[/PROWLER_UI_CONTEXT_V1]\n\nQuestion",
|
||||
display_text: "Question",
|
||||
},
|
||||
toolCallOutcome: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
updatedAt: "2026-06-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
render(<MessageBubble message={userMessage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Question")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/PROWLER_UI_CONTEXT_V1/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render persisted user context as a read-only historical badge", () => {
|
||||
// Given
|
||||
const userMessage: LighthouseV2Message = {
|
||||
id: "message-user-context",
|
||||
role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [
|
||||
{
|
||||
id: "part-user-context",
|
||||
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
|
||||
content: {
|
||||
text: "technical prompt",
|
||||
display_text: "Question",
|
||||
ui_context: {
|
||||
schema_version: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "findings",
|
||||
source: "automatic",
|
||||
scope_key: "findings:/findings",
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scope_key: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
finding_id: "finding-1",
|
||||
check_id: "aws_s3_bucket_public_access",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
toolCallOutcome: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
updatedAt: "2026-06-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
render(<MessageBubble message={userMessage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("@ Findings · Detail")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Remove Findings context/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render assistant text and tool calls in persisted part order", () => {
|
||||
// Given
|
||||
const orderedMessage = buildAssistantMessage([
|
||||
@@ -70,62 +157,6 @@ describe("MessageBubble", () => {
|
||||
expect(isBefore(firstText, toolCall)).toBe(true);
|
||||
expect(isBefore(toolCall, secondText)).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep wide assistant tables inside the message width", () => {
|
||||
// Given
|
||||
const wideTableMessage = buildAssistantMessage([
|
||||
textPart(
|
||||
"part-1",
|
||||
"| very-wide-header | another-wide-header |\n| --- | --- |\n| very-long-cell-value-that-should-not-resize-the-message | value |",
|
||||
),
|
||||
]);
|
||||
|
||||
// When
|
||||
render(<MessageBubble message={wideTableMessage} />);
|
||||
|
||||
// Then
|
||||
const table = screen.getByRole("table", {
|
||||
name: "Wide markdown table",
|
||||
});
|
||||
const markdown = table.closest(".lighthouse-markdown");
|
||||
if (!(markdown instanceof HTMLElement)) {
|
||||
throw new Error("Expected markdown wrapper around assistant table");
|
||||
}
|
||||
|
||||
expect(markdown).toHaveClass("min-w-0", "max-w-full", "overflow-x-auto");
|
||||
expect(markdown.parentElement).toHaveClass("min-w-0");
|
||||
expect(markdown.parentElement?.parentElement).toHaveClass(
|
||||
"min-w-0",
|
||||
"max-w-full",
|
||||
);
|
||||
expect(markdown.parentElement?.parentElement?.parentElement).toHaveClass(
|
||||
"min-w-0",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Mermaid diagrams inside the constrained markdown wrapper", () => {
|
||||
// Given
|
||||
const mermaidMessage = buildAssistantMessage([
|
||||
textPart("part-1", "```mermaid\ngraph TD\n A --> B\n```"),
|
||||
]);
|
||||
|
||||
// When
|
||||
render(<MessageBubble message={mermaidMessage} />);
|
||||
|
||||
// Then
|
||||
const mermaid = screen.getByRole("img", { name: "Mermaid chart" });
|
||||
const markdown = mermaid.closest(".lighthouse-markdown");
|
||||
if (!(markdown instanceof HTMLElement)) {
|
||||
throw new Error("Expected markdown wrapper around Mermaid diagram");
|
||||
}
|
||||
|
||||
expect(markdown).toHaveClass("min-w-0", "max-w-full", "overflow-x-auto");
|
||||
expect(markdown.parentElement).toHaveClass("min-w-0");
|
||||
expect(markdown.parentElement?.parentElement).toHaveClass(
|
||||
"min-w-0",
|
||||
"max-w-full",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function isBefore(first: HTMLElement, second: HTMLElement): boolean {
|
||||
|
||||
@@ -4,13 +4,17 @@ import { Bot, Check, Copy, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
|
||||
import { getTextContent } from "@/app/(prowler)/lighthouse/_lib/messages";
|
||||
import {
|
||||
getLighthouseContext,
|
||||
getTextContent,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/messages";
|
||||
import {
|
||||
LIGHTHOUSE_V2_MESSAGE_ROLE,
|
||||
LIGHTHOUSE_V2_PART_TYPE,
|
||||
type LighthouseV2Message,
|
||||
type LighthouseV2Part,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { LighthouseContextBadge } from "@/components/lighthouse/context-chip";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -39,6 +43,12 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
.map((part) => getTextContent(part.content))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const messageContext = isUser
|
||||
? message.parts
|
||||
.filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT)
|
||||
.map((part) => getLighthouseContext(part.content))
|
||||
.find((context) => context !== undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -54,6 +64,7 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
isUser ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
{messageContext && <LighthouseContextBadge context={messageContext} />}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 rounded-[8px] px-4 py-3 text-sm",
|
||||
@@ -65,7 +76,7 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
{/* User text stays plain to preserve HTML-like tags; assistant
|
||||
renders parts in order so tool calls sit between text blocks. */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{messageText}</p>
|
||||
<p className="wrap-break-word whitespace-pre-wrap">{messageText}</p>
|
||||
) : (
|
||||
<AssistantParts parts={message.parts} />
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,10 @@ import userEvent from "@testing-library/user-event";
|
||||
import { type ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resetPanelChatStoreForTests } from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import {
|
||||
requestPanelChatMessage,
|
||||
resetPanelChatStoreForTests,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import { notifyLighthouseV2ConfigurationsChanged } from "@/app/(prowler)/lighthouse/_lib/session-events";
|
||||
import { stubEventSource } from "@/app/(prowler)/lighthouse/_lib/testing/event-source-mock";
|
||||
import type {
|
||||
@@ -11,6 +14,13 @@ import type {
|
||||
LighthouseV2Session,
|
||||
LighthouseV2SupportedModel,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import {
|
||||
buildAttackPathContext,
|
||||
buildFocusedFindingContext,
|
||||
} from "@/lib/lighthouse/context/contributions";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
|
||||
|
||||
import {
|
||||
LighthousePanelChat,
|
||||
@@ -52,6 +62,11 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
updateLighthouseV2Configuration: updateConfigurationMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => window.location.pathname,
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}));
|
||||
|
||||
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
|
||||
// jsdom; render its text passthrough so message bodies are still assertable.
|
||||
vi.mock("streamdown", () => ({
|
||||
@@ -96,6 +111,7 @@ describe("LighthousePanelChat", () => {
|
||||
stubEventSource();
|
||||
resetPanelChatStoreForTests();
|
||||
resetPanelChatConfigCacheForTests();
|
||||
resetLighthouseContextStore();
|
||||
|
||||
getConfigurationsMock.mockResolvedValue({ data: configurations });
|
||||
getSupportedProvidersMock.mockResolvedValue({
|
||||
@@ -108,6 +124,11 @@ describe("LighthousePanelChat", () => {
|
||||
getSupportedModelsMock.mockResolvedValue({ data: [model("gpt-5.1")] });
|
||||
getSessionsMock.mockResolvedValue({ data: [] });
|
||||
getMessagesMock.mockResolvedValue({ data: [] });
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/findings?filter%5Bseverity__in%5D=critical",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -181,6 +202,136 @@ describe("LighthousePanelChat", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits a queued contextual analysis when the panel chat becomes ready", async () => {
|
||||
// Given
|
||||
const context = {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
buildFocusedFindingContext({
|
||||
pathname: "/findings",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
providerUid: "123456789012",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
region: "eu-west-1",
|
||||
}),
|
||||
],
|
||||
} satisfies LighthouseContextEnvelope;
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: session("session-context", "Analyze this finding"),
|
||||
});
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-context",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
requestPanelChatMessage("Analyze this finding", context);
|
||||
|
||||
// When
|
||||
render(<LighthousePanelChat />);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayText: "Analyze this finding",
|
||||
context: expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends page, focused finding, and parent Attack Path context together", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, "", "/attack-paths?scanId=scan-1");
|
||||
const contextStore = useLighthouseContextStore.getState();
|
||||
contextStore.registerContribution(
|
||||
"attack-path-current",
|
||||
buildAttackPathContext({
|
||||
pathname: "/attack-paths",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
queryLabel: "Internet-exposed resources",
|
||||
}),
|
||||
);
|
||||
contextStore.setFocusedContext(
|
||||
1,
|
||||
buildFocusedFindingContext({
|
||||
pathname: "/attack-paths",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
providerUid: "123456789012",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
region: "eu-west-1",
|
||||
}),
|
||||
);
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: session("session-context", "Explain this finding"),
|
||||
});
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-context",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<LighthousePanelChat />);
|
||||
const input = await screen.findByRole("textbox", { name: "Message" });
|
||||
expect(screen.getByText("@ Attack Paths · Detail")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.type(input, "Explain this finding{Enter}");
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayText: "Explain this finding",
|
||||
context: expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: "page",
|
||||
id: "attack-paths",
|
||||
filters: { scanId: ["scan-1"] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "attack_path",
|
||||
id: "current-query",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a recent chat in place without navigating", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
setPanelChatMessageState,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-message-state";
|
||||
import {
|
||||
flushPendingPanelChatMessage,
|
||||
getOrCreatePanelChatStore,
|
||||
resetPanelChatStore,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
@@ -166,6 +167,7 @@ function PanelChatReady({ config, modelsError }: PanelChatReadyProps) {
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
flushPendingPanelChatMessage();
|
||||
void refreshSessions();
|
||||
const syncPanelChatState = () => {
|
||||
const chatState = store.getState();
|
||||
|
||||
@@ -146,9 +146,6 @@ describe("createLighthouseChatStore", () => {
|
||||
displayText: " Summarize critical findings ",
|
||||
context,
|
||||
});
|
||||
expect(store.getState().lastSubmittedText).toBe(
|
||||
" Summarize critical findings ",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the model selected when submission starts", async () => {
|
||||
@@ -184,9 +181,9 @@ describe("createLighthouseChatStore", () => {
|
||||
it("retries with the original context snapshot", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
const context = findingsContext();
|
||||
const context = focusedFindingsContext();
|
||||
await store.getState().submitMessage("Prioritize findings", context);
|
||||
context.items[0].label = "Mutated after send";
|
||||
context.items[1].label = "Mutated after send";
|
||||
eventSources[0].fail(2 /* EventSource.CLOSED */);
|
||||
sendMessageMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
@@ -201,57 +198,12 @@ describe("createLighthouseChatStore", () => {
|
||||
expect(sendMessageMock).toHaveBeenNthCalledWith(2, {
|
||||
sessionId: "session-1",
|
||||
displayText: "Prioritize findings",
|
||||
context: findingsContext(),
|
||||
context: focusedFindingsContext(),
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries with the original snapshot even when current context was disabled", async () => {
|
||||
const store = makeStore();
|
||||
const context = findingsContext();
|
||||
await store.getState().submitMessage("Prioritize findings", context);
|
||||
eventSources[0].fail(2 /* EventSource.CLOSED */);
|
||||
store.getState().disableContext();
|
||||
|
||||
await store.getState().retryLastMessage();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenNthCalledWith(2, {
|
||||
sessionId: "session-1",
|
||||
displayText: "Prioritize findings",
|
||||
context,
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
expect(store.getState().isContextEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps context disabled for the conversation and restores it for a new chat", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
store.getState().disableContext();
|
||||
|
||||
// When
|
||||
await store
|
||||
.getState()
|
||||
.submitMessage("Question without context", findingsContext());
|
||||
|
||||
// Then
|
||||
expect(store.getState().isContextEnabled).toBe(false);
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
displayText: "Question without context",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
|
||||
// When
|
||||
store.getState().resetToNewChat();
|
||||
|
||||
// Then
|
||||
expect(store.getState().isContextEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("degrades oversized context before sending without blocking the message", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
@@ -695,6 +647,25 @@ function findingsContext(): LighthouseContextEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
function focusedFindingsContext(): LighthouseContextEnvelope {
|
||||
const context = findingsContext();
|
||||
return {
|
||||
...context,
|
||||
items: [
|
||||
...context.items,
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function oversizedFindingsContext(): LighthouseContextEnvelope {
|
||||
const context = findingsContext();
|
||||
return {
|
||||
|
||||
@@ -62,10 +62,7 @@ export interface LighthouseChatState {
|
||||
blockedByConflict: boolean;
|
||||
isSubmitting: boolean;
|
||||
isLoadingSession: boolean;
|
||||
/** @deprecated Use lastSubmission so retries can preserve their context snapshot. */
|
||||
lastSubmittedText: string | null;
|
||||
lastSubmission: LighthouseChatSubmission | null;
|
||||
isContextEnabled: boolean;
|
||||
selectedModelSelection: LighthouseV2ModelSelection | null;
|
||||
modelPreferenceSaving: boolean;
|
||||
setSessionUrlSyncEnabled: (enabled: boolean) => void;
|
||||
@@ -77,8 +74,6 @@ export interface LighthouseChatState {
|
||||
context?: LighthouseContextEnvelope,
|
||||
) => Promise<void>;
|
||||
retryLastMessage: () => Promise<void>;
|
||||
disableContext: () => void;
|
||||
enableContext: () => void;
|
||||
openSession: (sessionId: string) => Promise<void>;
|
||||
resetToNewChat: () => void;
|
||||
handleSessionArchived: (sessionId: string) => void;
|
||||
@@ -90,10 +85,6 @@ export interface LighthouseChatSubmission {
|
||||
context?: LighthouseContextEnvelope;
|
||||
}
|
||||
|
||||
interface LighthouseChatSubmitOptions {
|
||||
bypassContextGate?: boolean;
|
||||
}
|
||||
|
||||
export type LighthouseChatStore = StoreApi<LighthouseChatState>;
|
||||
|
||||
export function selectLighthouseChatCanSend(
|
||||
@@ -273,7 +264,6 @@ export function createLighthouseChatStore(
|
||||
const submitMessageInternal = async (
|
||||
displayText: string,
|
||||
context?: LighthouseContextEnvelope,
|
||||
submitOptions: LighthouseChatSubmitOptions = {},
|
||||
): Promise<void> => {
|
||||
if (!displayText.trim()) return;
|
||||
const selection = get().selectedModelSelection;
|
||||
@@ -284,11 +274,7 @@ export function createLighthouseChatStore(
|
||||
if (!selectLighthouseChatCanSend(get())) return;
|
||||
|
||||
const submissionVersion = ++submissionIntentVersion;
|
||||
const shouldUseContext =
|
||||
submitOptions.bypassContextGate === true || get().isContextEnabled;
|
||||
const contextSnapshot = shouldUseContext
|
||||
? prepareLighthouseContext(context)
|
||||
: undefined;
|
||||
const contextSnapshot = prepareLighthouseContext(context);
|
||||
|
||||
set({ isSubmitting: true });
|
||||
try {
|
||||
@@ -308,7 +294,6 @@ export function createLighthouseChatStore(
|
||||
set((current) => ({
|
||||
feedback: null,
|
||||
blockedByConflict: false,
|
||||
lastSubmittedText: displayText,
|
||||
lastSubmission,
|
||||
input: "",
|
||||
messages: [
|
||||
@@ -376,9 +361,7 @@ export function createLighthouseChatStore(
|
||||
blockedByConflict: false,
|
||||
isSubmitting: false,
|
||||
isLoadingSession: false,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
selectedModelSelection: resolveInitialModelSelection(
|
||||
connectedConfigurations,
|
||||
config.modelsByProvider,
|
||||
@@ -393,10 +376,6 @@ export function createLighthouseChatStore(
|
||||
|
||||
dismissFeedback: () => set({ feedback: null }),
|
||||
|
||||
disableContext: () => set({ isContextEnabled: false }),
|
||||
|
||||
enableContext: () => set({ isContextEnabled: true }),
|
||||
|
||||
selectModel: async (selection) => {
|
||||
// The selection drives the model used for the next message, so it stays
|
||||
// applied even if persisting it as the provider's default model fails —
|
||||
@@ -428,13 +407,7 @@ export function createLighthouseChatStore(
|
||||
retryLastMessage: async () => {
|
||||
const submission = get().lastSubmission;
|
||||
if (!submission) return;
|
||||
await submitMessageInternal(
|
||||
submission.displayText,
|
||||
submission.context,
|
||||
{
|
||||
bypassContextGate: true,
|
||||
},
|
||||
);
|
||||
await submitMessageInternal(submission.displayText, submission.context);
|
||||
},
|
||||
|
||||
openSession: async (sessionId) => {
|
||||
@@ -450,9 +423,7 @@ export function createLighthouseChatStore(
|
||||
blockedByConflict: false,
|
||||
isSubmitting: false,
|
||||
isLoadingSession: true,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
streamState: createInitialLighthouseV2StreamState(),
|
||||
});
|
||||
syncSessionUrl(sessionId);
|
||||
@@ -479,9 +450,7 @@ export function createLighthouseChatStore(
|
||||
blockedByConflict: false,
|
||||
isSubmitting: false,
|
||||
isLoadingSession: false,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
streamState: createInitialLighthouseV2StreamState(),
|
||||
});
|
||||
syncSessionUrl(null);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
createLighthouseV2Session: vi.fn(),
|
||||
getLighthouseV2Messages: vi.fn(),
|
||||
sendLighthouseV2Message: vi.fn(),
|
||||
updateLighthouseV2Configuration: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { LighthouseChatConfig } from "./chat-store";
|
||||
import {
|
||||
getOrCreatePanelChatStore,
|
||||
requestPanelChatMessage,
|
||||
resetPanelChatStoreForTests,
|
||||
} from "./panel-chat-store";
|
||||
|
||||
const EMPTY_CHAT_CONFIG: LighthouseChatConfig = {
|
||||
configurations: [],
|
||||
modelsByProvider: {
|
||||
openai: [],
|
||||
bedrock: [],
|
||||
"openai-compatible": [],
|
||||
},
|
||||
supportedProviders: [],
|
||||
};
|
||||
|
||||
describe("panel chat message request", () => {
|
||||
afterEach(() => {
|
||||
resetPanelChatStoreForTests();
|
||||
});
|
||||
|
||||
it("should start a new chat before submitting through an existing store", () => {
|
||||
// Given
|
||||
const store = getOrCreatePanelChatStore(EMPTY_CHAT_CONFIG);
|
||||
store.setState({ activeSessionId: "existing-session" });
|
||||
const resetToNewChat = vi.spyOn(store.getState(), "resetToNewChat");
|
||||
const submitMessage = vi
|
||||
.spyOn(store.getState(), "submitMessage")
|
||||
.mockResolvedValue();
|
||||
|
||||
// When
|
||||
requestPanelChatMessage("Analyze this finding");
|
||||
|
||||
// Then
|
||||
expect(resetToNewChat).toHaveBeenCalledOnce();
|
||||
expect(submitMessage).toHaveBeenCalledWith(
|
||||
"Analyze this finding",
|
||||
undefined,
|
||||
);
|
||||
expect(resetToNewChat.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMessage.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("should cancel an initial submission before sending a contextual request", () => {
|
||||
// Given: the store is still creating its first session
|
||||
const store = getOrCreatePanelChatStore(EMPTY_CHAT_CONFIG);
|
||||
store.setState({ isSubmitting: true });
|
||||
const resetToNewChat = vi.spyOn(store.getState(), "resetToNewChat");
|
||||
const submitMessage = vi
|
||||
.spyOn(store.getState(), "submitMessage")
|
||||
.mockResolvedValue();
|
||||
|
||||
// When
|
||||
requestPanelChatMessage("Analyze this finding");
|
||||
|
||||
// Then
|
||||
expect(resetToNewChat).toHaveBeenCalledOnce();
|
||||
expect(submitMessage).toHaveBeenCalledWith(
|
||||
"Analyze this finding",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
createLighthouseChatStore,
|
||||
type LighthouseChatConfig,
|
||||
type LighthouseChatSubmission,
|
||||
type LighthouseChatStore,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/chat-store";
|
||||
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
|
||||
|
||||
// Module-level singleton: the global side panel keeps the same conversation
|
||||
// while switching between Details and Lighthouse AI, across route navigation
|
||||
// and panel closes. The full-page route can reuse it for the same conversation.
|
||||
let panelChatStore: LighthouseChatStore | null = null;
|
||||
let pendingPanelChatMessage: LighthouseChatSubmission | null = null;
|
||||
|
||||
interface PanelChatStoreOptions {
|
||||
initialError?: string;
|
||||
@@ -27,6 +30,39 @@ export function getOrCreatePanelChatStore(
|
||||
return panelChatStore;
|
||||
}
|
||||
|
||||
export function requestPanelChatMessage(
|
||||
displayText: string,
|
||||
context?: LighthouseContextEnvelope,
|
||||
): void {
|
||||
if (panelChatStore) {
|
||||
const chatState = panelChatStore.getState();
|
||||
const hasActiveConversation =
|
||||
chatState.activeSessionId !== null ||
|
||||
chatState.messages.length > 0 ||
|
||||
chatState.streamState.activeTaskId !== null ||
|
||||
chatState.isSubmitting;
|
||||
if (hasActiveConversation) {
|
||||
chatState.resetToNewChat();
|
||||
}
|
||||
void panelChatStore.getState().submitMessage(displayText, context);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPanelChatMessage = context
|
||||
? { displayText, context }
|
||||
: { displayText };
|
||||
}
|
||||
|
||||
export function flushPendingPanelChatMessage(): void {
|
||||
if (!panelChatStore || !pendingPanelChatMessage) return;
|
||||
|
||||
const message = pendingPanelChatMessage;
|
||||
pendingPanelChatMessage = null;
|
||||
void panelChatStore
|
||||
.getState()
|
||||
.submitMessage(message.displayText, message.context);
|
||||
}
|
||||
|
||||
// Lets the full-page surface reuse the singleton only when both surfaces point
|
||||
// at the same conversation. This is intentionally a pure lookup: React may
|
||||
// run state initializers twice in Strict Mode.
|
||||
@@ -54,4 +90,5 @@ export function resetPanelChatStore(): void {
|
||||
|
||||
export function resetPanelChatStoreForTests(): void {
|
||||
resetPanelChatStore();
|
||||
pendingPanelChatMessage = null;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const { getAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
const { getRouteAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getRouteAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getRouteAuthHeaders: getRouteAuthHeadersMock,
|
||||
}));
|
||||
|
||||
describe("GET /api/scans/[scanId]/report", () => {
|
||||
@@ -17,6 +20,24 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 without fetching upstream when authentication is invalid", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getRouteAuthHeadersMock.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({ error: "Unauthorized." });
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams the upstream report body without buffering it", async () => {
|
||||
const upstreamBody = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -34,7 +55,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -69,7 +92,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -92,7 +117,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -119,7 +146,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -142,7 +171,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
Response.json({ data: { id: "task-1" } }, { status: 202 }),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -157,7 +188,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -184,7 +217,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getRouteAuthHeaders } from "@/lib/auth-headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -63,7 +64,14 @@ export async function GET(
|
||||
{ params }: ScanReportRouteContext,
|
||||
) {
|
||||
const { scanId } = await params;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const headers = await getRouteAuthHeaders({ contentType: false });
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized." },
|
||||
{ status: 401, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`;
|
||||
const isPreflight =
|
||||
new URL(request.url).searchParams.get("preflight") === "1";
|
||||
|
||||
@@ -45,6 +45,33 @@ const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
|
||||
manage_scans: true,
|
||||
};
|
||||
|
||||
// Access token whose "exp" claim is in the past (2001), so the JWT callback
|
||||
// takes the refresh branch.
|
||||
const EXPIRED_ACCESS_TOKEN =
|
||||
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6MTAwMDAwMDAwMH0.signature";
|
||||
// Access token whose "exp" claim is far in the future (2100).
|
||||
const ROTATED_ACCESS_TOKEN =
|
||||
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6NDEwMjQ0NDgwMH0.signature";
|
||||
|
||||
const refreshResponse = (accessToken: string, refreshToken: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
type: "tokens-refresh",
|
||||
attributes: { access: accessToken, refresh: refreshToken },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const blacklistedRefreshResponse = () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
errors: [{ detail: "Token is blacklisted", code: "token_not_valid" }],
|
||||
}),
|
||||
});
|
||||
|
||||
describe("authConfig JWT callback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -195,3 +222,115 @@ describe("authConfig JWT callback", () => {
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authConfig token refresh with rotated refresh tokens", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
// The rotated-pair cache is module scoped, so each test needs its own refresh
|
||||
// token to stay independent.
|
||||
const expiredToken = (refreshToken: string) => ({
|
||||
accessToken: EXPIRED_ACCESS_TOKEN,
|
||||
refreshToken,
|
||||
tenant_id: "tenant-1",
|
||||
user: {
|
||||
name: "Tenant User",
|
||||
email: "tenant@example.com",
|
||||
dateJoined: "2026-01-01",
|
||||
permissions: RESTRICTED_PERMISSIONS,
|
||||
},
|
||||
});
|
||||
|
||||
it("should reuse the rotated token pair when the previous refresh could not be persisted", async () => {
|
||||
// Given a refresh that succeeds but whose cookie is never written (Server
|
||||
// Component render), the API blacklists "refresh-token-1" on rotation, so a
|
||||
// second refresh with the same stale cookie would be rejected.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
refreshResponse(ROTATED_ACCESS_TOKEN, "refresh-token-2"),
|
||||
)
|
||||
.mockResolvedValueOnce(blacklistedRefreshResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When the same stale token is presented twice
|
||||
const firstResult = await jwtCallback({
|
||||
token: expiredToken("stale-cookie-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
const secondResult = await jwtCallback({
|
||||
token: expiredToken("stale-cookie-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then the rotated pair is reused instead of burning a blacklisted token
|
||||
expect(firstResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "refresh-token-2",
|
||||
});
|
||||
expect(secondResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "refresh-token-2",
|
||||
});
|
||||
expect(secondResult.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should still report a terminal error when the refresh token is genuinely rejected", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn().mockResolvedValue(blacklistedRefreshResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When
|
||||
const result = await jwtCallback({
|
||||
token: expiredToken("rejected-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.error).toBe("RefreshAccessTokenError");
|
||||
});
|
||||
|
||||
it("should retry against the API after a transient refresh failure", async () => {
|
||||
// Given a network failure followed by a healthy response
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network unreachable"))
|
||||
.mockResolvedValueOnce(
|
||||
refreshResponse(ROTATED_ACCESS_TOKEN, "retried-refresh-token"),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When
|
||||
const failedResult = await jwtCallback({
|
||||
token: expiredToken("transient-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
const retriedResult = await jwtCallback({
|
||||
token: expiredToken("transient-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then the failure is not cached and the retry recovers the session
|
||||
expect(failedResult.error).toBe("RefreshAccessTokenError");
|
||||
expect(retriedResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "retried-refresh-token",
|
||||
});
|
||||
expect(retriedResult.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user