Compare commits

...
Author SHA1 Message Date
Alan Buscaglia 16b6214ade fix(ui): reuse rotated refresh tokens across requests
- Cache the rotated credential pair so a stale session cookie no longer
  replays a blacklisted refresh token
- Keep refresh failures uncached so transient errors can retry
- Cover pair reuse, genuine rejection, and transient retry
2026-07-29 16:40:22 +02:00
Alan Buscaglia 8d0573d89c chore(ui): merge master into expired-session fix 2026-07-29 15:54:34 +02:00
Hugo Pereira Brito 34e4d25576 fix(ocsf): use provider MITRE catalog for attacks (#12157) 2026-07-29 13:05:47 +01:00
Daniel Barranquero d4a33c0d1c feat(ui): link every Attack Paths query to its Prowler Hub page (#12145) 2026-07-29 13:31:42 +02:00
Alejandro Bailo 4c3017e2ed fix(ui): wrap long Lighthouse chat messages (#12215) 2026-07-29 11:55:52 +02:00
Alan Buscaglia 627d3b6199 fix(ui): return 401 for unauthenticated report routes
- Preserve redirects for server-rendered authentication flows
- Return explicit unauthorized responses from report route handlers
- Cover invalid sessions and callback fallback behavior
2026-07-29 11:35:14 +02:00
Adrián Peña 03f2ab46c9 fix(api): refresh Security Hub connection status (#12212) 2026-07-29 11:17:25 +02:00
Alan BuscagliaandPablo F.G 7f1cdb82ae feat(ui): add PostHog-backed in-app feedback survey (#12116)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-29 11:12:38 +02:00
Hugo Pereira Brito 1bb3fc9bda ci: add release freeze gate (#11621) 2026-07-29 10:08:15 +01:00
Hugo Pereira Brito 463e8309f7 fix(sdk): render inline code with valid ADF marks (#12158) 2026-07-29 09:47:14 +01:00
Alejandro Bailo 59a7d30a3e fix(ui): restore finding delta colors and shorten update labels (#12160) 2026-07-29 10:31:22 +02:00
Alejandro Bailo 2ae1062e76 feat(ui): add contextual Lighthouse page UX (#12069) 2026-07-29 10:06:02 +02:00
Alan Buscaglia ecfea481ef chore(ui): merge master into expired-session fix 2026-07-28 10:41:50 +02:00
Pablo F.G eb039d6f77 chore(ui): add SessionError type 2026-07-22 17:06:32 +02:00
Alan Buscaglia af61400681 test(ui): prebundle server-only in browser tests 2026-07-21 14:52:19 +02:00
Alan Buscaglia bcb6ddb950 test(ui): remove redundant auth header mocks 2026-07-21 14:33:35 +02:00
Alan Buscaglia dabed66b3f chore(ui): merge master into expired-session fix 2026-07-20 18:37:06 +02:00
Alan Buscaglia b0b3ecec00 fix(ui): isolate server-only auth headers 2026-07-20 18:16:22 +02:00
Alan Buscaglia 34079dcc09 docs(ui): add expired session redirect changelog 2026-07-20 10:20:28 +02:00
Alan Buscaglia bb411eb4bc fix(ui): redirect expired sessions to sign-in 2026-07-20 10:19:07 +02:00
198 changed files with 7391 additions and 1224 deletions
+7
View File
@@ -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:
+40
View File
@@ -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
+4 -1
View File
@@ -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
+3 -1
View File
@@ -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"}:
+53 -20
View File
@@ -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)
+45 -1
View File
@@ -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)
+114 -57
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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";
+3
View File
@@ -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 -1
View File
@@ -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,
+3
View File
@@ -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 -1
View File
@@ -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";
+2 -1
View File
@@ -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(
+1 -1
View File
@@ -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,
}));
+2 -5
View File
@@ -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 -1
View File
@@ -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,
+3
View File
@@ -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,
}));
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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", () => {
+2 -1
View File
@@ -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 -1
View File
@@ -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";
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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,
+14 -1
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+2 -1
View File
@@ -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,
+4 -1
View File
@@ -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",
+2 -1
View File
@@ -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";
+4 -1
View File
@@ -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,
}));
+1 -1
View File
@@ -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";
+3
View File
@@ -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,
}));
+2 -1
View File
@@ -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,
+4 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+3
View File
@@ -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,
}));
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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) => {
+2 -1
View File
@@ -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 () => {
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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";
@@ -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();
});
});
@@ -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(),
@@ -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";
+6 -4
View File
@@ -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 {
+2 -33
View File
@@ -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;
}
+45 -10
View File
@@ -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"),
+10 -2
View File
@@ -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";
+139
View File
@@ -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