chore(ui): merge master into expired-session fix

This commit is contained in:
Alan Buscaglia committed 2026-07-28 10:41:50 +02:00
commit ecfea481ef
568 files changed
+23697 -2930

No files matched your search

+29
View File
@@ -4,6 +4,35 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.36.0] (Prowler v5.36.0)
### 🚀 Added
- Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts [(#12001)](https://github.com/prowler-cloud/prowler/pull/12001)
- In Prowler Cloud, the Attack Paths query selector now lists only queries that returned data for the selected scan, hiding empty ones [(#12010)](https://github.com/prowler-cloud/prowler/pull/12010)
- Overview banner linking to the AI agents documentation, shown next to the Lighthouse AI banner in Cloud and full width on self-hosted deployments [(#12074)](https://github.com/prowler-cloud/prowler/pull/12074)
### 🐞 Fixed
- Findings Severity Over Time chart Y-axis labels no longer overflow for large findings counts [(#11545)](https://github.com/prowler-cloud/prowler/pull/11545)
- UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures [(#11665)](https://github.com/prowler-cloud/prowler/pull/11665)
- OCI provider E2E tests no longer require or submit a region when adding or updating credentials [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
- Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments [(#12047)](https://github.com/prowler-cloud/prowler/pull/12047)
- AWS Organizations setup modal now shows the "Enter a valid Organizational Unit or Root ID" hint in the error color, clarifying why the deployment button is disabled [(#12063)](https://github.com/prowler-cloud/prowler/pull/12063)
- Sidebar logo top spacing in the main app sidebar [(#12066)](https://github.com/prowler-cloud/prowler/pull/12066)
- Contextual Cloud upgrade modal content remains stable throughout the closing animation [(#12067)](https://github.com/prowler-cloud/prowler/pull/12067)
- Tenant switches now refresh session user permissions for the selected tenant [(#12087)](https://github.com/prowler-cloud/prowler/pull/12087)
### 🔐 Security
- Removed the unused `npm` CLI from the UI container image, eliminating the bundled `node-tar` `CVE-2026-59873` (and future bundled-npm CVEs); the image builds with `pnpm` via `corepack` and does not use `npm` [(#12065)](https://github.com/prowler-cloud/prowler/pull/12065)
- Bumped `vitest` and `@vitest/browser`, `@vitest/browser-playwright`, `@vitest/coverage-v8` from `4.1.8` to `4.1.10`, resolving the critical `@vitest/browser` Browser Mode file-access permission bypass (`GHSA-p63j-vcc4-9vmv`) flagged by `pnpm audit`; dev dependencies only, no runtime impact [(#12077)](https://github.com/prowler-cloud/prowler/pull/12077)
- Kubernetes credential forms now reject kubeconfig files using legacy `auth-provider.config.cmd-path` command authentication [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
- Next.js from 16.2.9 to 16.2.11, patching 4 high- and 5 medium-severity vulnerabilities [(#12093)](https://github.com/prowler-cloud/prowler/pull/12093)
- next-auth from 5.0.0-beta.30 to 5.0.0-beta.32, patching 2 critical Auth.js advisories (GHSA-8fpg-xm3f-6cx3 fail-open auth checks, GHSA-7rqj-j65f-68wh email homoglyph bypass) [(#12108)](https://github.com/prowler-cloud/prowler/pull/12108)
---
## [1.35.0] (Prowler v5.35.0)
### 🔄 Changed
+6 -3
View File
@@ -4,7 +4,9 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
LABEL maintainer="https://github.com/prowler-cloud"
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop
# the bundled-npm CVE surface (node-tar CVE-2026-59873) from every stage, incl. prod.
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dependencies only when needed
FROM base AS deps
@@ -79,9 +81,10 @@ ENV HOSTNAME="0.0.0.0"
# Helm/K8s):
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
# - optional: UI_API_DOCS_URL
# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
# - gated integrations (load only when *_ENABLED="true"; the value is then
# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST)
# still activate without the flag:
# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*,
# 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)
+105 -2
View File
@@ -1,12 +1,26 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
type AttackPathQueryParameter,
type AttackPathQueryResultSummary,
} from "@/types/attack-paths";
import { buildAttackPathQueries } from "./queries.adapter";
import {
adaptAttackPathQueriesResponse,
buildAttackPathQueries,
} from "./queries.adapter";
vi.mock("@/lib/shared/env", () => ({ isCloud: vi.fn() }));
// Empty-query filtering only applies in Prowler Cloud; default the flag on for
// the filtering suite and cover the self-hosted (flag off) case explicitly.
beforeEach(() => {
vi.mocked(isCloud).mockReturnValue(true);
});
const presetQuery: AttackPathQuery = {
type: "attack-paths-scans",
@@ -21,6 +35,95 @@ const presetQuery: AttackPathQuery = {
},
};
const makeQuery = (
id: string,
overrides: {
result_summary?: AttackPathQueryResultSummary | null;
parameters?: AttackPathQueryParameter[];
} = {},
): AttackPathQuery => ({
type: "attack-paths-scans",
id,
attributes: {
name: id,
short_description: "",
description: "",
provider: "aws",
attribution: null,
parameters: overrides.parameters ?? [],
result_summary: overrides.result_summary,
},
});
describe("adaptAttackPathQueriesResponse filtering", () => {
it("keeps only queries with data, plus everything without a definite empty verdict", () => {
const response = {
data: [
makeQuery("has-data", {
result_summary: { status: "ok", has_data: true },
}),
makeQuery("empty", {
result_summary: { status: "ok", has_data: false },
}),
makeQuery("errored", {
result_summary: { status: "error", has_data: null },
}),
makeQuery("parameterized", {
parameters: [
{
name: "ip",
label: "IP",
data_type: "string",
} as AttackPathQueryParameter,
],
result_summary: null,
}),
makeQuery("no-summary"), // scan predating the precompute step
],
};
const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
// the only one hidden is the parameterless query known to be empty
expect(ids).toEqual(["has-data", "errored", "parameterized", "no-summary"]);
expect(ids).not.toContain("empty");
});
it("returns an empty list for a missing response", () => {
expect(adaptAttackPathQueriesResponse(undefined).data).toEqual([]);
});
it("updates the pagination count to the filtered length", () => {
const response = {
data: [
makeQuery("a", { result_summary: { status: "ok", has_data: true } }),
makeQuery("b", { result_summary: { status: "ok", has_data: false } }),
],
};
const { metadata } = adaptAttackPathQueriesResponse(response);
expect(metadata?.pagination.count).toBe(1);
});
it("shows every query when not running in Cloud (feature flag off)", () => {
vi.mocked(isCloud).mockReturnValue(false);
const response = {
data: [
makeQuery("has-data", {
result_summary: { status: "ok", has_data: true },
}),
makeQuery("empty", {
result_summary: { status: "ok", has_data: false },
}),
],
};
const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
// self-hosted keeps current behaviour: no filtering, even the empty one
expect(ids).toEqual(["has-data", "empty"]);
});
});
describe("buildAttackPathQueries", () => {
it("prepends a custom query that links to the Prowler documentation", () => {
// When
+23 -1
View File
@@ -1,4 +1,5 @@
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
@@ -19,6 +20,27 @@ import {
* - Maintains separation of concerns between API layer and business logic
*/
/**
* Decide whether a query should appear in the selector.
*
* Empty-query filtering is a Prowler Cloud feature: outside Cloud
* (`isCloud()` is false) the queries API does not precompute result summaries,
* so every query is shown, matching current self-hosted behaviour.
*
* In Cloud, hide only queries the scan precomputed and found empty
* (`has_data === false`). Everything else stays visible: parameterized queries
* (no summary, run live on demand), queries that errored during precompute
* (`has_data` null), and scans that predate the precompute step (no summary at
* all). A missing/null summary is therefore never treated as "empty".
*/
function shouldShowQuery(query: AttackPathQuery): boolean {
if (!isCloud()) {
return true;
}
const summary = query.attributes.result_summary;
return !summary || summary.has_data !== false;
}
/**
* Adapt attack path queries response with enriched data
*
@@ -36,7 +58,7 @@ export function adaptAttackPathQueriesResponse(
}
// Enrich query data with computed properties
const enrichedData = response.data.map((query) => ({
const enrichedData = response.data.filter(shouldShowQuery).map((query) => ({
...query,
// Can add computed properties here, e.g.:
// parameterCount: query.attributes.parameters.length,
@@ -235,7 +235,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const input = {
data: [
{
@@ -291,7 +291,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should leave triage editing disabled until a real capability is provided", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const input = {
data: [
{
+2
View File
@@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Findings-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FINDINGS_EXTRA_FIELD = {
CHECK_ID: "check_id",
CHECK_ID_IN: "check_id__in",
DELTA_IN: "delta__in",
SCAN_EXACT: "scan",
SCAN_ID: "scan_id",
@@ -1,3 +1,4 @@
import { isCloud } from "@/lib/shared/env";
import {
FINDING_TRIAGE_DISABLED_REASON,
type FindingTriageDisabledReason,
@@ -9,7 +10,7 @@ interface FindingTriageAdapterOptions {
}
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
return {
canEdit: isCloudEnvironment,
+2 -2
View File
@@ -58,7 +58,7 @@ describe("findings actions triage projection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue(findingsResponse);
@@ -86,7 +86,7 @@ describe("findings actions triage projection", () => {
it("should attach domain triage DTOs to latest findings responses", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const result = await getLatestFindings({ page: 1, pageSize: 10 });
+149 -64
View File
@@ -1,129 +1,170 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
pollTaskUntilSettledMock: vi.fn(),
}));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollTaskUntilSettledMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
}));
vi.mock("@/lib/auth-headers", () => ({
getAuthHeaders: vi.fn(),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: vi.fn(),
handleApiError: () => ({ error: "An error occurred" }),
}));
import { pollJiraDispatchTask } from "./jira-dispatch";
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollTaskUntilSettledMock,
}));
describe("pollJiraDispatchTask", () => {
vi.mock("@/lib/auth-headers", () => ({
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
}));
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
describe("sendJiraDispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: "task-1",
type: "tasks",
attributes: { result: null },
},
}),
{ status: 202 },
),
);
});
it("should return the backend error when a completed task has failed Jira dispatches", async () => {
it("should send grouped dispatch mode with multiple finding IDs", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["finding-1", "finding-2"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: {
attributes: {
dispatch_mode: "grouped",
issue_type: "Task",
project_key: "SEC",
},
},
});
});
it("should send grouped dispatch mode with a finding group check ID", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["s3_bucket_public_access"],
filter: "check_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: { attributes: { dispatch_mode: "grouped" } },
});
});
it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 2,
failed_count: 1,
error: "Jira project requires custom fields: Team is required",
failed_finding_ids: ["finding-3"],
error: "Jira rejected one Finding.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira project requires custom fields: Team is required",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.",
failedFindingIds: ["finding-3"],
});
});
it("should return a fallback error when a completed task has failures without an error", async () => {
it("should include updated issues in partial failure summaries", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 1,
},
result: { created_count: 0, updated_count: 2, failed_count: 1 },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
it("should fail completed task polling when grouped dispatch reports failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 3,
},
result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create 3 Jira issues.",
success: true,
message: "Finding successfully sent to Jira!",
warning:
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
it("should surface task failure result errors", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "failed",
result: {
error: "Jira credentials are invalid.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
// Then
expect(result).toEqual({
success: false,
error: "Jira credentials are invalid.",
});
});
it("should return success when a completed task has no failures", async () => {
it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 1,
failed_count: 0,
},
result: { created_count: 1, failed_groups: [] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
@@ -132,24 +173,68 @@ describe("pollJiraDispatchTask", () => {
});
});
it("should return a fallback error when no Jira issue was created", async () => {
it("should succeed completed task polling with grouped created and updated issue result shape", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 1,
updated_count: 1,
failed_count: 0,
created_issues: [{ key: "SEC-1" }],
updated_issues: [{ key: "SEC-2" }],
failed_groups: [],
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", {
maxAttempts: 5,
delayMs: 2000,
});
expect(result).toEqual({
success: true,
message: "2 Jira issues were created or updated successfully.",
});
});
it("should fail completed task polling when Jira dispatch completed as a no-op", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 0, updated_count: 0, failed_count: 0 },
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
error: "Jira dispatch completed but did not create or update any issues.",
});
});
it("should fail completed task polling when Jira dispatch has no result payload", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: null,
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira dispatch completed but did not create or update any issues.",
});
});
});
+47 -29
View File
@@ -3,12 +3,26 @@
import { pollTaskUntilSettled } from "@/actions/task/poll";
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 {
IntegrationProps,
JiraDispatchMode,
JiraDispatchRequest,
JiraDispatchResponse,
JiraDispatchTarget,
JiraDispatchTaskResult,
} from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
interface JiraDispatchInput {
integrationId: string;
targetIds: string[];
filter: JiraDispatchTarget;
projectKey: string;
issueType: string;
dispatchMode?: JiraDispatchMode;
}
export const getJiraIssueTypes = async (
integrationId: string,
@@ -88,14 +102,37 @@ export const sendFindingToJira = async (
): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
return sendJiraDispatch({
integrationId,
targetIds: [findingId],
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
projectKey,
issueType,
});
};
export const sendJiraDispatch = async ({
integrationId,
targetIds,
filter,
projectKey,
issueType,
dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
}: JiraDispatchInput): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
);
// Single finding: use direct filter without array notation
url.searchParams.append("filter[finding_id]", findingId);
if (targetIds.length === 1) {
url.searchParams.append(`filter[${filter}]`, targetIds[0]);
} else {
url.searchParams.append(`filter[${filter}__in]`, targetIds.join(","));
}
const payload: JiraDispatchRequest = {
data: {
@@ -103,6 +140,7 @@ export const sendFindingToJira = async (
attributes: {
project_key: projectKey,
issue_type: issueType,
dispatch_mode: dispatchMode,
},
},
};
@@ -146,38 +184,18 @@ export const sendFindingToJira = async (
export const pollJiraDispatchTask = async (
taskId: string,
): Promise<
{ success: true; message: string } | { success: false; error: string }
| { success: true; message: string; warning?: string }
| { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
maxAttempts: 30,
maxAttempts: 5,
delayMs: 2000,
});
if (!res.ok) {
return { success: false, error: res.error };
}
const { state, result } = res;
type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"];
const jiraResult = result as JiraTaskResult | undefined;
if (state === "completed") {
const createdCount = jiraResult?.created_count ?? 0;
const failedCount = jiraResult?.failed_count ?? 0;
if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
return { success: true, message: "Finding successfully sent to Jira!" };
}
return {
success: false,
error:
jiraResult?.error ||
(failedCount > 1
? `Failed to create ${failedCount} Jira issues.`
: "Failed to create Jira issue."),
};
}
if (state === "failed") {
return { success: false, error: jiraResult?.error || "Task failed." };
}
return { success: false, error: `Unknown task state: ${state}` };
return evaluateJiraDispatchTask(
res.state,
res.result as JiraDispatchTaskResult | undefined,
);
};
@@ -1,5 +1,5 @@
import { LucideIcon } from "lucide-react";
import {
LucideIcon,
Activity,
BarChart3,
Bot,
+2 -1
View File
@@ -13,6 +13,7 @@ import {
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";
import { OrganizationResource } from "@/types/organizations";
export const getResources = async ({
@@ -287,7 +288,7 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
await Promise.all([
+3 -3
View File
@@ -77,7 +77,7 @@ describe("role actions", () => {
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await addRole(makeRoleFormData());
@@ -88,7 +88,7 @@ describe("role actions", () => {
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
await addRole(makeRoleFormData());
@@ -101,7 +101,7 @@ describe("role actions", () => {
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await updateRole(makeRoleFormData(), "role-1");
+3 -2
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { apiBaseUrl } from "@/lib";
import { getAuthHeaders } from "@/lib/auth-headers";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { isCloud } from "@/lib/shared/env";
export const getRoles = async ({
page = 1,
@@ -109,7 +110,7 @@ export const addRole = async (formData: FormData) => {
};
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
@@ -166,7 +167,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
};
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
+7 -3
View File
@@ -1,6 +1,10 @@
import { AuthForm } from "@/components/auth/oss";
import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper";
import { isGoogleOAuthEnabled } from "@/lib/helper";
import {
getAuthUrl,
isGithubOAuthEnabled,
isGoogleOAuthEnabled,
} from "@/lib/helper";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
const SignUp = async ({
@@ -13,7 +17,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
@@ -1,52 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
describe("LighthouseOverviewBanner", () => {
it("renders Toni copy and opens a prompted chat when connected", () => {
// Given / When
render(
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
);
// Then
const link = screen.getByRole("link", {
name: /Find and remediate what actually matters\./,
});
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
expect(link).toHaveTextContent("Lighthouse AI");
expect(link).toHaveTextContent("Find and remediate what actually matters.");
});
it("links to Lighthouse settings when no connected configuration exists", () => {
// Given / When
render(
<LighthouseOverviewBanner
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
/>,
);
// Then
expect(
screen.getByRole("link", {
name: /Find and remediate what actually matters\./,
}),
).toHaveAttribute("href", "/lighthouse/settings");
});
it("isolates its stacking so content never paints over the sticky navbar", () => {
// Given / When: the banner's inner z-10 must stay scoped to the card —
// without isolation it ties the sticky header's z-10 and wins by DOM order
render(
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
);
// Then
const card = screen
.getByRole("link", { name: /Find and remediate what actually matters\./ })
.querySelector("[data-slot='card']");
expect(card).toHaveClass("isolate");
});
});
@@ -0,0 +1,150 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
import { OVERVIEW_BANNER_VARIANT } from "../_lib/overview-banner";
import { OverviewBanner } from "./overview-banner";
describe("OverviewBanner", () => {
it("renders Toni copy and opens a prompted chat when connected", () => {
// Given / When
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// Then
const link = screen.getByRole("link", {
name: /Find and remediate what actually matters\./,
});
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
expect(link).toHaveTextContent("Lighthouse AI");
expect(link).toHaveTextContent("Find and remediate what actually matters.");
});
it("links to Lighthouse settings when no connected configuration exists", () => {
// Given / When
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
/>,
);
// Then
const link = screen.getByRole("link", {
name: /Find and remediate what actually matters\./,
});
expect(link).toHaveAttribute("href", "/lighthouse/settings");
// In-app hrefs stay in the current tab
expect(link).not.toHaveAttribute("target");
});
it("opens the AI agents docs in a new tab", () => {
// Given / When
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>,
);
// Then
const link = screen.getByRole("link", {
name: /Connect all your agents to Prowler Cloud/,
});
expect(link).toHaveAttribute("href", DOCS_URLS.AI_AGENTS);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link).toHaveTextContent(
"Turn your favorite agent into a Cloud Security Expert.",
);
});
it("uses the purple agents palette with the same animated layers", () => {
// Given / When
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>,
);
// Then
const link = screen.getByRole("link", {
name: /Connect all your agents to Prowler Cloud/,
});
const gradient = link.querySelector(".overview-banner-gradient");
expect(gradient).toHaveClass("overview-banner-gradient-agents");
expect(gradient?.querySelector(".animate-first")).toBeInTheDocument();
expect(gradient?.querySelector(".animate-second")).toBeInTheDocument();
expect(gradient?.querySelector(".animate-third")).toBeInTheDocument();
expect(
gradient?.querySelector(".overview-banner-gradient-primary-press"),
).toBeInTheDocument();
});
it("keeps the Lighthouse banner on the default green palette", () => {
// Given / When
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// Then
const link = screen.getByRole("link", {
name: /Find and remediate what actually matters\./,
});
const gradient = link.querySelector(".overview-banner-gradient");
expect(gradient).not.toHaveClass("overview-banner-gradient-agents");
});
it("scopes the blur filter id per instance so stacked banners keep their gradient", () => {
// Given / When: url(#id) resolves against the FIRST match in the document,
// so two banners sharing one id would both resolve to the same filter
const { container } = render(
<>
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>
</>,
);
// Then
const filterIds = Array.from(
container.querySelectorAll("filter"),
(filter) => filter.id,
);
expect(filterIds).toHaveLength(2);
expect(new Set(filterIds).size).toBe(2);
});
it("isolates its stacking so content never paints over the sticky navbar", () => {
// Given / When: the banner's inner z-10 must stay scoped to the card —
// without isolation it ties the sticky header's z-10 and wins by DOM order
render(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// Then
const card = screen
.getByRole("link", { name: /Find and remediate what actually matters\./ })
.querySelector("[data-slot='card']");
expect(card).toHaveClass("isolate");
});
});
@@ -1,29 +1,55 @@
"use client";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Bot } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import { type ReactNode, useId, useRef, useState } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Card, CardContent } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { cn } from "@/lib/utils";
import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner";
import {
OVERVIEW_BANNER_VARIANT,
type OverviewBannerVariant,
} from "../_lib/overview-banner";
interface LighthouseOverviewBannerProps {
href: LighthouseOverviewBannerHref;
// Content lives here rather than at the call site so the icons stay inside the
// client boundary — LighthouseIcon uses useId and cannot render on the server.
const OVERVIEW_BANNER_CONTENT = {
lighthouse: {
icon: <LighthouseIcon className="size-5" />,
title: "Lighthouse AI",
description: "Find and remediate what actually matters.",
},
agents: {
icon: <Bot className="size-5" />,
title: "Connect all your agents to Prowler Cloud",
description: "Turn your favorite agent into a Cloud Security Expert.",
},
} as const satisfies Record<
OverviewBannerVariant,
{ icon: ReactNode; title: string; description: string }
>;
interface OverviewBannerProps {
variant: OverviewBannerVariant;
href: string;
}
export function LighthouseOverviewBanner({
href,
}: LighthouseOverviewBannerProps) {
export function OverviewBanner({ variant, href }: OverviewBannerProps) {
const { icon, title, description } = OVERVIEW_BANNER_CONTENT[variant];
// Absolute hrefs leave the app, so they open in a new tab.
const isExternal = href.startsWith("http");
const interactiveRef = useRef<HTMLDivElement>(null);
const curXRef = useRef(0);
const curYRef = useRef(0);
const tgXRef = useRef(0);
const tgYRef = useRef(0);
const [isSafari, setIsSafari] = useState(false);
// Several banners render per page and url(#id) resolves against the FIRST
// matching id in the document, so the filter id must be per-instance.
const blurFilterId = `overview-banner-blur-${useId().replace(/[«»:]/g, "")}`;
useMountEffect(() => {
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
@@ -61,19 +87,22 @@ export function LighthouseOverviewBanner({
return (
<Link
href={href}
className="group focus-visible:ring-border-input-primary block rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: undefined)}
className="group focus-visible:ring-border-input-primary block h-full rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Card
variant="base"
padding="none"
// isolate: the content's internal z-10 (above the gradient layers)
// must not compete with the page's sticky header, which is also z-10.
className="group-hover:border-border-input-primary relative isolate overflow-hidden transition-colors"
className="group-hover:border-border-input-primary relative isolate h-full overflow-hidden transition-colors"
onMouseMove={handleMouseMove}
>
<svg className="hidden">
<defs>
<filter id="blurMe">
<filter id={blurFilterId}>
<feGaussianBlur
in="SourceGraphic"
stdDeviation="10"
@@ -92,33 +121,41 @@ export function LighthouseOverviewBanner({
<div
className={cn(
"pointer-events-none absolute inset-0 blur-lg",
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
"overview-banner-gradient pointer-events-none absolute inset-0 blur-lg",
variant === OVERVIEW_BANNER_VARIANT.AGENTS
? "overview-banner-gradient-agents"
: undefined,
isSafari ? "blur-2xl" : undefined,
)}
style={
isSafari
? undefined
: { filter: `url(#${blurFilterId}) blur(40px)` }
}
>
<div className="animate-first lighthouse-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
<div className="animate-first overview-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
<div className="animate-second lighthouse-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
<div className="animate-second overview-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
<div className="animate-third lighthouse-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
<div className="animate-third overview-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
<div
ref={interactiveRef}
className="lighthouse-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
className="overview-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
/>
</div>
<CardContent className="relative z-10 flex min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<CardContent className="relative z-10 flex h-full min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
<LighthouseIcon className="size-5" />
{icon}
</span>
<div className="min-w-0">
<p className="text-text-neutral-primary text-sm font-medium">
Lighthouse AI
{title}
</p>
<p className="text-text-neutral-secondary text-sm">
Find and remediate what actually matters.
{description}
</p>
</div>
</div>
@@ -0,0 +1,7 @@
export const OVERVIEW_BANNER_VARIANT = {
LIGHTHOUSE: "lighthouse",
AGENTS: "agents",
} as const;
export type OverviewBannerVariant =
(typeof OVERVIEW_BANNER_VARIANT)[keyof typeof OVERVIEW_BANNER_VARIANT];
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { AttackSurface } from "./_components/attack-surface";
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
@@ -13,6 +13,7 @@ import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
@@ -7,6 +7,7 @@ import {
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import { RiskRadarViewClient } from "./risk-radar-view-client";
export async function RiskRadarViewSSR({
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ResourcesInventory } from "./_components/resources-inventory";
export const ResourcesInventorySSR = async ({
@@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChart } from "./_components/risk-severity-chart";
export const RiskSeverityChartSSR = async ({
@@ -15,6 +15,7 @@ import {
} from "@/types/severities";
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
@@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ThreatScore } from "./_components/threat-score";
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
export const ComplianceWatchlistSSR = async ({
@@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ServiceWatchlist } from "./_components/service-watchlist";
export const ServiceWatchlistSSR = async ({
@@ -28,6 +28,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
import {
createAlert,
deleteAlert,
@@ -15,12 +15,10 @@ import {
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Button, useToast } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
import type { MetaDataProps } from "@/types";
import type { ScanEntity } from "@/types";
import type { MetaDataProps, ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { toAlertPayload } from "../_lib/alert-adapter";
@@ -29,6 +27,7 @@ import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { AlertFormModal } from "./alert-form-modal";
import { AlertsEmptyState } from "./alerts-empty-state";
import { AlertsTable } from "./alerts-table";
@@ -28,8 +28,9 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
ToastAction,
useToast,
} from "@/components/shadcn";
import { ToastAction, useToast } from "@/components/shadcn";
import { useCloudUpgradeStore } from "@/store";
import type { ScanEntity } from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
@@ -31,9 +31,11 @@ import {
getNodeColor,
getPathEdges,
GRAPH_EDGE_HIGHLIGHT_COLOR,
isProwlerFindingNode,
resolveHiddenFindingIds,
} from "../../_lib";
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
import { layoutWithDagre } from "../../_lib/layout";
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
import { ResourceNode } from "./nodes/resource-node";
@@ -387,7 +389,7 @@ const GraphCanvas = ({
const findingToResources = new Map<string, Set<string>>();
nodes.forEach((n) => {
if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
});
const resourcesWithFindings = new Set<string>();
@@ -459,7 +461,7 @@ const GraphCanvas = ({
hidden: hiddenFindingIds.has(node.id),
className: cn(
node.className,
isFindingNode(node.data.graphNode.labels) ||
isProwlerFindingNode(node.data.graphNode.labels) ||
resourcesWithFindings.has(node.id)
? "cursor-pointer"
: "cursor-default",
@@ -23,6 +23,7 @@ import {
GRAPH_NODE_COLORS,
} from "../../_lib/graph-colors";
import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
import { isProwlerFindingNode } from "../../_lib/node-types";
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
const LEGEND_PREVIEW = {
@@ -197,7 +198,7 @@ const edgeItems: LegendEdgeItem[] = [
];
const isFindingNode = (node: GraphNode): boolean =>
node.labels.some((label) => label.toLowerCase().includes("finding"));
isProwlerFindingNode(node.labels);
const getGraphEdges = (
data: AttackPathGraphData,
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
interface FindingNodeData {
@@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
interface InternetNodeData {
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
interface ResourceNodeData {
@@ -50,6 +50,15 @@ const resourceNode: GraphNode = {
},
};
const guardDutyNode: GraphNode = {
id: "guard-duty-node-id",
labels: ["GuardDutyFinding"],
properties: {
id: "guard-duty-123",
title: "Port probe",
},
};
describe("NodeDetailPanel", () => {
it("renders the view finding button only for finding nodes", () => {
const { rerender } = render(<NodeDetailPanel node={findingNode} />);
@@ -65,6 +74,17 @@ describe("NodeDetailPanel", () => {
).not.toBeInTheDocument();
});
it("does not render the view finding button for cloud-provider finding resources", () => {
// Given/When
render(<NodeDetailPanel node={guardDutyNode} />);
// Then
expect(
screen.queryByRole("button", { name: /view finding/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Node findings")).toBeInTheDocument();
});
it("calls onViewFinding with the node finding id", async () => {
const user = userEvent.setup();
const onViewFinding = vi.fn();
@@ -11,6 +11,8 @@ import {
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { GraphNode } from "@/types/attack-paths";
import { isProwlerFindingNode } from "../../_lib";
import { NodeFindings } from "./node-findings";
import { NodeOverview } from "./node-overview";
import { NodeResources } from "./node-resources";
@@ -37,9 +39,7 @@ export const NodeDetailContent = ({
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}) => {
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-6">
@@ -105,9 +105,7 @@ export const NodeDetailPanel = ({
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = node ? isProwlerFindingNode(node.labels) : false;
const findingId = node ? String(node.properties?.id || node.id) : "";
return (
@@ -5,7 +5,7 @@ import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabels } from "../../_lib";
import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
interface NodeOverviewProps {
node: GraphNode;
@@ -25,9 +25,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
return String(value);
};
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-4">
@@ -22,6 +22,7 @@ import {
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
import { isProwlerFindingNode } from "./node-types";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -67,8 +68,7 @@ const downloadDataUrl = (dataUrl: string, filename: string) => {
document.body.removeChild(link);
};
const isFindingNode = (labels: string[]) =>
labels.some((label) => label.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
if (graphData.edges?.length) return graphData.edges;
@@ -1,3 +1,5 @@
import { isProwlerFindingNode } from "./node-types";
/**
* Color constants for attack path graph visualization
* Colors chosen to work well in both light and dark themes
@@ -67,7 +69,7 @@ export const getNodeColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
@@ -100,7 +102,7 @@ export const getNodeBorderColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
@@ -4,6 +4,8 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
import { isProwlerFindingNode } from "./node-types";
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
@@ -103,11 +105,11 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const sourceIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.source) ?? [],
);
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const targetIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.target) ?? [],
);
// Include findings connected to the selected node
@@ -18,6 +18,7 @@ export {
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
export {
NODE_CATEGORY,
type NodeCategory,
@@ -17,6 +17,18 @@ const resourceNode: GraphNode = {
properties: { name: "bucket-1" },
};
const guardDutyNode: GraphNode = {
id: "guard-duty-1",
labels: ["GuardDutyFinding"],
properties: { title: "Port probe", severity: "high" },
};
const inspectorNode: GraphNode = {
id: "inspector-1",
labels: ["AWSInspectorFinding"],
properties: { title: "Package vulnerability", severity: "high" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
@@ -54,6 +66,42 @@ describe("layoutWithDagre", () => {
});
});
it("treats cloud-provider finding resources as resource nodes", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, guardDutyNode, inspectorNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")?.type).toBe("finding");
expect(byId.get("guard-duty-1")).toMatchObject({
type: "resource",
width: 136,
height: 124,
});
expect(byId.get("inspector-1")?.type).toBe("resource");
});
it("does not animate edges that only touch cloud-provider finding resources", () => {
const { rfEdges } = layoutWithDagre(
[resourceNode, guardDutyNode],
[
{
id: "e1",
source: "guard-duty-1",
target: "resource-1",
type: "AFFECTS",
},
],
);
expect(rfEdges[0]).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
@@ -13,6 +13,7 @@ import {
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { isProwlerFindingNode } from "./node-types";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -34,8 +35,7 @@ const NODE_TYPE = {
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
export const isFindingNode = (labels: string[]): boolean =>
labels.some((l) => l.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
@@ -0,0 +1,8 @@
const normalizeNodeLabel = (label: string): string =>
label.toLowerCase().replace(/[^a-z0-9]/g, "");
export const isProwlerFindingLabel = (label: string): boolean =>
normalizeNodeLabel(label) === "prowlerfinding";
export const isProwlerFindingNode = (labels: string[]): boolean =>
labels.some(isProwlerFindingLabel);
@@ -193,6 +193,28 @@ describe("resolveNodeVisual", () => {
});
});
it("should resolve cloud-provider finding resources as non-finding nodes", () => {
// Given
const guardDutyNode = buildNode(["GuardDutyFinding"], {
title: "Port probe",
severity: "high",
});
const inspectorNode = buildNode(["AWSInspectorFinding"], {
title: "Package vulnerability",
severity: "high",
});
// When
const guardDutyVisual = resolveNodeVisual(guardDutyNode);
const inspectorVisual = resolveNodeVisual(inspectorNode);
// Then
expect(guardDutyVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(guardDutyVisual.description).toBe("Guard Duty Finding");
expect(inspectorVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(inspectorVisual.description).toBe("Aws Inspector Finding");
});
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
@@ -49,6 +49,7 @@ import {
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
import { isProwlerFindingLabel } from "./node-types";
export const NODE_CATEGORY = {
FINDING: "finding",
@@ -396,8 +397,7 @@ const normalizeLabel = (label: string): string =>
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
label in KNOWN_NODE_VISUALS;
const isFindingLabel = (label: string): boolean =>
normalizeLabel(label).includes("finding");
const isFindingLabel = isProwlerFindingLabel;
const isInternetLabel = (label: string): boolean =>
normalizeLabel(label) === "internet";
@@ -35,8 +35,8 @@ vi.mock("@/actions/findings", async () => {
});
import { useGraphStore } from "./_hooks/use-graph-state";
import { getPathEdges } from "./_lib";
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
import { getPathEdges, isProwlerFindingNode } from "./_lib";
import { layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
@@ -268,7 +268,7 @@ describe("running a query", () => {
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
(node) => !isFindingNode(node.labels),
(node) => !isProwlerFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
@@ -477,7 +477,7 @@ describe("exploring the graph", () => {
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
.filter((node) => isFindingNode(node.labels))
.filter((node) => isProwlerFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
@@ -7,6 +7,7 @@
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
@@ -458,9 +459,7 @@ export class AttackPathPageHarness {
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -528,9 +527,7 @@ export class AttackPathPageHarness {
async expandAllFindings(): Promise<void> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -28,13 +28,13 @@ import {
import { StatusAlert } from "@/components/shared/status-alert";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "@/lib/tours/attack-paths.tour";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
import type {
AttackPathQuery,
@@ -60,7 +60,7 @@ import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
@@ -325,9 +325,7 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
if (isFinding) {
if (findingNavigationInFlightRef.current) {
@@ -347,9 +345,7 @@ export default function AttackPathsPage() {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
return otherNode ? isProwlerFindingNode(otherNode.labels) : false;
});
if (hasFindings) {
@@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { COMPLIANCE_TAB } from "../_types";
import { CompliancePageTabs } from "./compliance-page-tabs";
import { getComplianceTab } from "./compliance-page-tabs.shared";
@@ -29,6 +29,7 @@ import {
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
@@ -11,6 +11,7 @@ import {
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
CROSS_PROVIDER_OVERVIEW_TYPE,
} from "../_types";
import { CrossProviderOverview } from "./cross-provider-overview";
vi.mock("../_actions/cross-provider", () => ({
@@ -15,6 +15,7 @@ import {
} from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { CrossProviderRequirementExtras } from "../_types";
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
const { clientAccordionContentMock } = vi.hoisted(() => ({
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderBreakdownEntry } from "../_types";
import { ProviderCoverageCard } from "./provider-coverage-card";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
+14
View File
@@ -24,6 +24,7 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
@@ -68,6 +69,13 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +118,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
@@ -145,6 +154,10 @@ const SSRDataTable = async ({
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
: expandedCheckIdParam;
const { encodedSort } = extractSortAndKey(searchParams);
const hasHistoricalData = hasDateOrScanFilter(filters);
@@ -178,6 +191,7 @@ const SSRDataTable = async ({
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
/>
</>
);
@@ -1,4 +1,3 @@
import React from "react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
@@ -29,6 +29,7 @@ import {
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ProviderIcon } from "../config/provider-icon";
import { ChatComposerPanel } from "./composer";
import { ChatEmptyState } from "./empty-state";
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
@@ -170,11 +170,12 @@ describe("LighthousePanelChat", () => {
// When
render(<LighthousePanelChat />);
// Then: composer is live and the empty state lists recent chats
// Then: composer is live and the empty state lists recent chats.
// Sessions load on a later render than the composer, so await them.
expect(
await screen.findByRole("textbox", { name: "Message" }),
).toBeInTheDocument();
expect(screen.getByText("Recent chats")).toBeInTheDocument();
expect(await screen.findByText("Recent chats")).toBeInTheDocument();
expect(
await screen.findByText("Counting critical findings"),
).toBeInTheDocument();
@@ -42,6 +42,7 @@ import {
LIGHTHOUSE_CHAT_SURFACE,
LighthouseV2ChatView,
} from "../chat/lighthouse-v2-chat-view";
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
const PANEL_CHAT_STATUS = {
@@ -6,6 +6,7 @@ import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
@@ -44,7 +45,7 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -12,8 +12,7 @@ import {
} from "@/actions/lighthouse-v1/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
import { Button } from "@/components/shadcn";
import { NavigationHeader } from "@/components/shadcn";
import { Button, NavigationHeader } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
@@ -27,7 +28,7 @@ function SelectModelContent() {
}
export default function SelectModelPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -15,8 +15,8 @@ import {
FieldError,
Skeleton,
Textarea,
useToast,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { fontMono } from "@/config/fonts";
@@ -4,8 +4,7 @@ import { useState } from "react";
import { toggleMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Switch } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Switch, useToast } from "@/components/shadcn";
interface MuteRuleEnabledToggleProps {
muteRule: MuteRuleData;
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { type MuteRuleTableData } from "./mute-rule-target-previews";
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
@@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
const longMuteRule: MuteRuleTableData = {
type: "mute-rules",
id: "mute-rule-1",
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("Overview page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "page.tsx");
const source = readFileSync(filePath, "utf8");
it("renders the overview banners before the provider filters", () => {
// Given
const firstBannerPosition = source.indexOf("<OverviewBanner");
const firstProviderFilterPosition = source.indexOf(
"<ProviderAccountSelectors",
);
// When
const bannersRenderBeforeFilters =
firstBannerPosition < firstProviderFilterPosition;
// Then
expect(firstBannerPosition).toBeGreaterThan(-1);
expect(firstProviderFilterPosition).toBeGreaterThan(-1);
expect(bannersRenderBeforeFilters).toBe(true);
});
});
+22 -7
View File
@@ -10,11 +10,13 @@ import {
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { LighthouseOverviewBanner } from "./_overview/_components/lighthouse-overview-banner";
import { OverviewBanner } from "./_overview/_components/overview-banner";
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
@@ -58,17 +60,30 @@ export default async function Home({
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
{/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a
local server the agents banner is the only child and fills the row. */}
<div className="mb-6 flex flex-col gap-6 lg:flex-row">
{lighthouseBannerHref ? (
<div className="min-w-0 lg:flex-1">
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={lighthouseBannerHref}
/>
</div>
) : null}
<div className="min-w-0 lg:flex-1">
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>
</div>
</div>
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
{lighthouseBannerHref ? (
<div className="mb-6">
<LighthouseOverviewBanner href={lighthouseBannerHref} />
</div>
) : null}
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
@@ -17,8 +17,8 @@ import {
FieldLabel,
Input,
Textarea,
useToast,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
@@ -10,8 +10,7 @@ import {
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { Button, Card } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Button, Card, useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DataTable } from "@/components/shadcn/table";
+2 -2
View File
@@ -11,6 +11,8 @@ import {
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import {
appendPendingScheduleRowsToPage,
buildScheduledTabRows,
@@ -20,8 +22,6 @@ import {
getScanJobsUserFilters,
pickScheduleProviderFilters,
} from "@/components/scans/scans.utils";
import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/shadcn/content-layout";
+197
View File
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { authConfig } from "./auth.config";
import type { RolePermissionAttributes } from "./types/users";
const { getUserByMeMock } = vi.hoisted(() => ({
getUserByMeMock: vi.fn(),
}));
vi.mock("next-auth", () => ({
default: vi.fn(() => ({
signIn: vi.fn(),
signOut: vi.fn(),
auth: vi.fn(),
handlers: {},
})),
}));
vi.mock("next-auth/providers/credentials", () => ({
default: vi.fn((config) => config),
}));
vi.mock("./actions/auth", () => ({
getToken: vi.fn(),
getUserByMe: getUserByMeMock,
}));
vi.mock("./lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
}));
const RESTRICTED_PERMISSIONS: RolePermissionAttributes = {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_alerts: false,
unlimited_visibility: false,
};
const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
...RESTRICTED_PERMISSIONS,
manage_users: true,
manage_scans: true,
};
describe("authConfig JWT callback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should load elevated tenant permissions after switching from a restricted tenant", async () => {
// Given
const accessToken =
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMiJ9.signature";
getUserByMeMock.mockResolvedValue({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: "restricted-access-token",
refreshToken: "restricted-refresh-token",
tenant_id: "tenant-1",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
trigger: "update",
session: {
accessToken,
refreshToken: "elevated-refresh-token",
},
});
// Then
expect(getUserByMeMock).toHaveBeenCalledWith(accessToken);
expect(result.user).toEqual({
name: "Tenant User",
email: "tenant@example.com",
companyName: "Tenant Company",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
});
});
it("should load restricted tenant permissions after switching from an elevated tenant", async () => {
// Given
const accessToken =
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSJ9.signature";
getUserByMeMock.mockResolvedValue({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: "elevated-access-token",
refreshToken: "elevated-refresh-token",
tenant_id: "tenant-2",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
trigger: "update",
session: {
accessToken,
refreshToken: "restricted-refresh-token",
},
});
// Then
expect(getUserByMeMock).toHaveBeenCalledWith(accessToken);
expect(result.accessToken).toBe(accessToken);
expect(result.refreshToken).toBe("restricted-refresh-token");
expect(result.tenant_id).toBe("tenant-1");
expect(result.user).toMatchObject({
permissions: RESTRICTED_PERMISSIONS,
});
});
it("should report a tenant switch failure while preserving the current session", async () => {
// Given
vi.spyOn(console, "warn").mockImplementation(() => undefined);
getUserByMeMock.mockRejectedValue(new Error("Temporary API failure"));
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const sessionCallback = authConfig.callbacks?.session;
if (!sessionCallback) throw new Error("Session callback is not configured");
const currentToken = {
accessToken: "current-access-token",
refreshToken: "current-refresh-token",
tenant_id: "tenant-1",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
},
};
// When
const result = await jwtCallback({
token: currentToken,
user: {} as Parameters<typeof jwtCallback>[0]["user"],
trigger: "update",
session: {
accessToken:
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMiJ9.signature",
refreshToken: "switched-refresh-token",
},
});
if (!result) throw new Error("JWT callback cleared the current token");
const session = await sessionCallback({
session: {
expires: "2026-12-31T23:59:59.999Z",
user: { name: "Tenant User" },
},
token: result,
} as Parameters<typeof sessionCallback>[0]);
// Then
expect(session).toMatchObject({
error: "TenantSwitchError",
accessToken: "current-access-token",
refreshToken: "current-refresh-token",
tenantId: "tenant-1",
user: {
permissions: RESTRICTED_PERMISSIONS,
},
});
expect(result.error).toBeUndefined();
});
});
+59 -21
View File
@@ -4,7 +4,6 @@ import NextAuth, {
type DefaultSession,
type NextAuthConfig,
type Session,
User,
} from "next-auth";
import type { JWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
@@ -58,8 +57,29 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
unlimited_visibility: false,
};
const TENANT_SWITCH_ERROR = "TenantSwitchError";
type TokenUserInput = Partial<TokenUser> & { company?: string };
type JwtCallback = NonNullable<NonNullable<NextAuthConfig["callbacks"]>["jwt"]>;
type JwtCallbackParams = Parameters<JwtCallback>[0];
interface JwtCallbackCredentials {
accessToken?: string;
refreshToken?: string;
}
type AuthJwtUser = JwtCallbackParams["user"] &
TokenUserInput &
JwtCallbackCredentials;
interface AuthJwtCallbackParams
extends Omit<JwtCallbackParams, "session" | "token" | "user"> {
session?: Partial<ExtendedSession>;
token: AuthToken;
user: AuthJwtUser;
}
const toTokenUser = (user?: TokenUserInput): TokenUser =>
({
name: user?.name ?? undefined,
@@ -308,37 +328,54 @@ export const authConfig = {
return true;
},
jwt: async ({ token, account, user, trigger, session }) => {
const authToken = token as AuthToken;
jwt: async ({
token: authToken,
account,
user,
trigger,
session,
}: AuthJwtCallbackParams): Promise<AuthToken> => {
// Handle tenant switch: update tokens from client-side useSession().update()
if (trigger === "update" && session?.accessToken) {
authToken.accessToken = session.accessToken;
authToken.refreshToken = session.refreshToken;
applyDecodedClaims(authToken, authToken.accessToken, "tenant switch");
return authToken;
const newAccessToken = session.accessToken;
try {
const userMeResponse = await getUserByMe(newAccessToken);
const nextAuthToken: AuthToken = {
...authToken,
accessToken: newAccessToken,
refreshToken: session.refreshToken,
user: tokenUserFromApi(userMeResponse),
error: undefined,
};
applyDecodedClaims(nextAuthToken, newAccessToken, "tenant switch");
return nextAuthToken;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Error refreshing user after tenant switch:", error);
return {
...authToken,
error: TENANT_SWITCH_ERROR,
};
}
}
applyDecodedClaims(authToken, authToken.accessToken);
if (account && user) {
const signedInUser = user as User &
TokenUserInput & {
accessToken: string;
refreshToken: string;
};
if (account && user?.accessToken && user.refreshToken) {
const nextAuthToken: AuthToken = {
...authToken,
accessToken: signedInUser.accessToken,
refreshToken: signedInUser.refreshToken,
user: toTokenUser(signedInUser),
accessToken: user.accessToken,
refreshToken: user.refreshToken,
user: toTokenUser(user),
error: undefined,
};
applyDecodedClaims(
nextAuthToken,
signedInUser.accessToken,
user.accessToken,
"access token on sign-in",
);
@@ -359,7 +396,7 @@ export const authConfig = {
const authToken = token as AuthToken;
const nextSession = { ...session } as ExtendedSession;
if (authToken?.error) {
if (authToken.error && authToken.error !== TENANT_SWITCH_ERROR) {
nextSession.error = authToken.error;
nextSession.user = undefined;
nextSession.userId = undefined;
@@ -369,7 +406,8 @@ export const authConfig = {
return nextSession;
}
nextSession.error = undefined;
nextSession.error = authToken.error;
authToken.error = undefined;
nextSession.userId = authToken.user_id ?? nextSession.userId;
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
nextSession.accessToken =
@@ -0,0 +1 @@
Attack Paths now classify cloud-provider finding resources separately from Prowler findings
@@ -0,0 +1 @@
Removed the unused `NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE` variable and its `getDeploymentMode` helper; no build ever set it and nothing read the result, so Cloud versus self-hosted behavior is decided solely by the runtime `UI_CLOUD_ENABLED` flag
@@ -1 +0,0 @@
Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
@@ -1 +0,0 @@
OCI provider E2E tests no longer require or submit a region when adding or updating credentials
@@ -1 +0,0 @@
UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures
+1 -1
View File
@@ -17,8 +17,8 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
useToast,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
+1 -2
View File
@@ -15,8 +15,7 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button, Checkbox } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Button, Checkbox, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
@@ -2,8 +2,7 @@
import { useRef, useState } from "react";
import { Button } from "@/components/shadcn";
import { Accordion, AccordionItemProps } from "@/components/shadcn";
import { Button, Accordion, AccordionItemProps } from "@/components/shadcn";
import { Card } from "@/components/shadcn/card/card";
export const ClientAccordionWrapper = ({
@@ -19,6 +19,7 @@ import {
import { ScanEntity } from "@/types/scans";
import { getComplianceIcon } from "../icons";
import { ComplianceDownloadContainer } from "./compliance-download-container";
interface ComplianceCardProps {
+28 -6
View File
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -42,6 +44,7 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
export const FindingsFilterBatchControls = ({
providers,
@@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
checkOptions = [],
trailingControls,
appliedFilters,
pendingFilters,
@@ -102,6 +107,18 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [
checkId,
checkTitle || checkId,
]),
);
const findingGroupFilterOption = buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
});
const customFilters = [
...filterFindings
@@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
checkTitles,
}),
})),
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
{
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
index: 4,
},
{
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
index: 5,
},
{
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
index: 9,
},
{
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
index: 5,
index: 6,
},
{
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
index: 6,
index: 7,
},
...(isAlertsEdit
? []
@@ -164,7 +183,7 @@ export const FindingsFilterBatchControls = ({
scans: scanDetails,
},
),
index: 7,
index: 8,
},
]),
];
@@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
@@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
@@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
});
return (
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -164,6 +165,30 @@ describe("getFindingsFilterDisplayValue", () => {
);
});
it("uses the finding group title for check_id filters when available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
),
).toBe("External Teams users can join meetings");
});
it("keeps the check id when no finding group title is available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
),
).toBe("teams_external_users_can_join");
});
it("uses the provider display name regardless of account alias/uid", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
@@ -298,6 +323,45 @@ describe("buildFindingsFilterChips", () => {
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
});
it("renders filter[check_id] as a first-class Finding Group chip", () => {
// Given - exact deep-link params from the grouped findings page.
const chips = buildFindingsFilterChips(
{
"filter[check_id]": ["teams_external_users_can_join"],
},
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
);
expect(chips).toEqual([
{
key: "filter[check_id]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "External Teams users can join meetings",
},
]);
});
it("renders filter[check_id__in] with the Finding Group chip label", () => {
const chips = buildFindingsFilterChips({
"filter[check_id__in]": ["teams_external_users_can_join"],
});
expect(chips).toEqual([
{
key: "filter[check_id__in]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "teams_external_users_can_join",
},
]);
});
it("skips muted filters because the table toolbar owns that control", () => {
const chips = buildFindingsFilterChips({
"filter[muted]": ["include"],
@@ -324,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
]);
});
});
describe("buildFindingGroupFilterOption", () => {
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
// Given
const filter = buildFindingGroupFilterOption({
checkOptions: [
{
checkId: "teams_external_users_can_join",
checkTitle: "External Teams users can join meetings",
},
],
selectedCheckIds: ["s3_bucket_public_access"],
selectedCheckIdsIn: ["teams_external_users_can_join"],
checkTitles: {
teams_external_users_can_join: "External Teams users can join meetings",
},
});
// Then
expect(filter).toMatchObject({
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
index: 3,
});
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
"External Teams users can join meetings",
);
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
"s3_bucket_public_access",
);
});
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
expect(
buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
}),
).toBeNull();
});
});
@@ -7,14 +7,21 @@ import {
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { ProviderGroup } from "@/types/components";
import type { FilterOption } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
export interface FindingCheckFilterOption {
checkId: string;
checkTitle?: string;
}
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
@@ -64,6 +71,12 @@ export function getFindingsFilterDisplayValue(
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
return getScanDisplayValue(value, options.scans || []);
}
if (
filterKey === "filter[check_id]" ||
filterKey === "filter[check_id__in]"
) {
return options.checkTitles?.[value] || value;
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
@@ -100,6 +113,43 @@ export function getFindingsFilterDisplayValue(
return formatLabel(value);
}
function uniqueNonEmptyValues(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
export function buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
...selectedCheckIds,
...selectedCheckIdsIn,
]);
if (values.length === 0) {
return null;
}
return {
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue("filter[check_id]", value, {
checkTitles,
}),
index: 3,
};
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
@@ -108,6 +158,8 @@ export function getFindingsFilterDisplayValue(
* label is missing.
*/
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
"filter[check_id]": "Finding Group",
"filter[check_id__in]": "Finding Group",
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
@@ -134,6 +186,7 @@ interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
includeMuted?: boolean;
}
@@ -22,7 +22,7 @@ vi.mock("next/navigation", () => ({
// Import after mocks
// ---------------------------------------------------------------------------
import { FloatingMuteButton } from "./floating-mute-button";
import { FloatingSelectionActions } from "./floating-selection-actions";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
@@ -39,7 +39,7 @@ function deferredPromise<T>() {
// Fix 3: onBeforeOpen rejection resets isResolving
// ---------------------------------------------------------------------------
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -50,7 +50,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -74,7 +74,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -113,7 +113,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -147,7 +147,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={["group-1", "group-2", "group-3"]}
onBeforeOpen={onBeforeOpen}
@@ -1,34 +1,57 @@
"use client";
import { VolumeX } from "lucide-react";
import { Ellipsis, VolumeX } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown/action-dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
import { MuteFindingsModal } from "./mute-findings-modal";
interface FloatingMuteButtonProps {
interface FloatingSelectionActionsBaseProps {
selectedCount: number;
selectedFindingIds: string[];
onComplete?: () => void;
/** Async resolver that returns actual finding UUIDs before opening modal */
/** Async resolver that returns actual finding UUIDs before opening modal. */
onBeforeOpen?: () => Promise<string[]>;
/** When true, the toast warns that processing may take a few minutes */
/** When true, the toast warns that processing may take a few minutes. */
isBulkOperation?: boolean;
/** Custom button label. Defaults to "Mute ({selectedCount})" */
/** Custom button label. Defaults to "{selectedCount} selected". */
label?: string;
/** Custom mute action label. Defaults to "Mute". */
muteLabel?: string;
}
export function FloatingMuteButton({
type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
(
| {
jiraPayload: JiraDispatchModalPayload;
jiraLabel: string;
}
| {
jiraPayload?: never;
jiraLabel?: never;
}
);
export function FloatingSelectionActions({
selectedCount,
selectedFindingIds,
onComplete,
onBeforeOpen,
isBulkOperation = false,
label,
}: FloatingMuteButtonProps) {
muteLabel = "Mute",
jiraPayload,
jiraLabel,
}: FloatingSelectionActionsProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
@@ -51,7 +74,7 @@ export function FloatingMuteButton({
}
};
const handleClick = async () => {
const handleMuteClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -103,20 +126,48 @@ export function FloatingMuteButton({
with the content. */}
{typeof document !== "undefined"
? createPortal(
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
>
{isResolving ? (
<Spinner className="size-5" />
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 flex gap-2 duration-300">
<div className="shadow-lg">
{jiraPayload ? (
<ActionDropdown
ariaLabel="Open selection actions"
trigger={
<Button disabled={isResolving} size="lg">
{isResolving ? (
<Spinner className="size-5" />
) : (
<Ellipsis className="size-5" />
)}
{label ?? `${selectedCount} selected`}
</Button>
}
>
<ActionDropdownItem
icon={<VolumeX />}
label={muteLabel}
aria-label={muteLabel}
onSelect={() => void handleMuteClick()}
/>
<JiraDispatchActionItem
label={jiraLabel}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
<VolumeX className="size-5" />
<Button
onClick={() => void handleMuteClick()}
disabled={isResolving}
size="lg"
>
{isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedCount})
</Button>
)}
{label ?? `Mute (${selectedCount})`}
</Button>
</div>
</div>,
document.body,
)
@@ -0,0 +1,105 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
JIRA_DISPATCH_TARGET,
type JiraDispatchTarget,
} from "@/types/integrations";
const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({
isGroupedJiraDispatchEnabledMock: vi.fn(() => false),
}));
vi.mock("@/lib/deployment", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/deployment")>()),
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
}));
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
const renderAction = (targetIds: string[], targetType: JiraDispatchTarget) => {
const selection = createJiraTargetSelection(targetIds, targetType)!;
render(
<ActionDropdown trigger={<button type="button">Actions</button>}>
<ActionDropdownItem label="Other action" />
<JiraDispatchActionItem label="Send to Jira" payload={{ selection }} />
</ActionDropdown>,
);
};
describe("JiraDispatchActionItem", () => {
beforeEach(() => {
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
useCloudUpgradeStore.getState().closeCloudUpgrade();
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("opens Jira modal payload for one Finding", async () => {
// Given
const user = userEvent.setup();
renderAction(["finding-1"], JIRA_DISPATCH_TARGET.FINDING_ID);
// When
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
// Then
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
selection: { targetId: "finding-1" },
});
expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
});
it("shows Cloud tooltip and opens upgrade for grouped dispatch", async () => {
// Given
const user = userEvent.setup();
renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID);
// When
await user.click(screen.getByRole("button", { name: "Actions" }));
const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" });
expect(
within(jiraAction).queryByText("Available only in Prowler Cloud"),
).not.toBeInTheDocument();
await user.hover(jiraAction);
// Then
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Available only in Prowler Cloud",
);
// When
await user.click(jiraAction);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
);
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
});
it("opens grouped Jira payload when feature is enabled", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
const user = userEvent.setup();
renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID);
// When
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
// Then
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
selection: { targetId: "check-1" },
});
});
});
@@ -0,0 +1,55 @@
"use client";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
interface JiraDispatchActionItemProps {
label: string;
payload: JiraDispatchModalPayload | null | undefined;
}
export const JiraDispatchActionItem = ({
label,
payload,
}: JiraDispatchActionItemProps) => {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const openJiraDispatch = useJiraDispatchStore(
(state) => state.openJiraDispatch,
);
if (!payload) return null;
const { requiresUpgrade } = getJiraDispatchActionState(
payload,
isGroupedJiraDispatchEnabled(),
);
const handleSelect = () => {
if (requiresUpgrade) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
openJiraDispatch(payload);
};
return (
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={label}
aria-label={label}
tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
onSelect={handleSelect}
/>
);
};
@@ -0,0 +1,67 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
() => ({
SendToJiraModalMock: vi.fn(() => null),
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
}),
);
vi.mock("./send-to-jira-modal", () => ({
SendToJiraModal: SendToJiraModalMock,
}));
vi.mock("@/lib/deployment", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/deployment")>()),
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
}));
import { JiraDispatchModalHost } from "./jira-dispatch-modal-host";
describe("JiraDispatchModalHost", () => {
beforeEach(() => {
vi.clearAllMocks();
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("renders one modal with derived grouped configuration", () => {
// Given
const selection = createJiraTargetSelection(
["check-1"],
JIRA_DISPATCH_TARGET.CHECK_ID,
)!;
useJiraDispatchStore.getState().openJiraDispatch({
selection,
selectedResourceCount: 3,
findingTitle: "Check title",
});
// When
render(<JiraDispatchModalHost />);
// Then
expect(SendToJiraModalMock).toHaveBeenCalledWith(
expect.objectContaining({
isOpen: true,
selection,
findingTitle: "Check title",
defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
canChooseGroupedDispatch: true,
}),
undefined,
);
});
it("does not render without an active payload", () => {
// Given / When
render(<JiraDispatchModalHost />);
// Then
expect(SendToJiraModalMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
"use client";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
import { useJiraDispatchStore } from "@/store";
import { SendToJiraModal } from "./send-to-jira-modal";
export const JiraDispatchModalHost = () => {
const activePayload = useJiraDispatchStore((state) => state.activePayload);
const closeJiraDispatch = useJiraDispatchStore(
(state) => state.closeJiraDispatch,
);
if (!activePayload) return null;
const { defaultDispatchMode, canChooseGroupedDispatch } =
getJiraDispatchActionState(activePayload, isGroupedJiraDispatchEnabled());
return (
<SendToJiraModal
isOpen
onOpenChange={(open) => !open && closeJiraDispatch()}
selection={activePayload.selection}
findingTitle={activePayload.findingTitle}
defaultDispatchMode={defaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedDispatch}
isFindingGroupSelection={activePayload.isFindingGroupSelection}
selectedResourceCount={activePayload.selectedResourceCount}
description={activePayload.description}
/>
);
};
@@ -0,0 +1,128 @@
import { type ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WatchedTask } from "@/store/task-watcher/store";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler";
interface ToastActionMockProps extends ComponentProps<"button"> {
altText: string;
}
const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted(
() => ({
sendJiraDispatchMock: vi.fn(),
toastMock: vi.fn(),
trackAndPollTaskMock: vi.fn(),
}),
);
vi.mock("@/actions/integrations/jira-dispatch", () => ({
sendJiraDispatch: sendJiraDispatchMock,
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
ToastAction: ({
altText: _altText,
children,
...props
}: ToastActionMockProps) => <button {...props}>{children}</button>,
}));
vi.mock("@/store/task-watcher/store", () => ({
trackAndPollTask: trackAndPollTaskMock,
}));
const buildTask = (result: unknown): WatchedTask => ({
taskId: "task-1",
kind: "jira-dispatch",
status: "ready",
startedAt: Date.now(),
meta: {
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
result,
});
describe("jiraDispatchTaskHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
sendJiraDispatchMock.mockResolvedValue({
success: true,
taskId: "retry-task",
message: "Started",
});
});
it("shows the completed Jira result after a persisted task resumes", () => {
// Given
const task = buildTask({ created_count: 2, failed_count: 0 });
// When
jiraDispatchTaskHandler.onReady(task);
// Then
expect(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "2 Jira issues were created or updated successfully.",
});
});
it("retries only failed Findings from a resumed partial task", async () => {
// Given
const task = buildTask({
created_count: 1,
failed_count: 2,
failed_finding_ids: ["finding-2", "finding-3"],
error: "Two Jira issues failed.",
});
jiraDispatchTaskHandler.onReady(task);
const partialToast = toastMock.mock.calls.at(-1)?.[0];
// When
await partialToast.action.props.onClick();
// Then
expect(sendJiraDispatchMock).toHaveBeenCalledWith({
integrationId: "jira-1",
targetIds: ["finding-2", "finding-3"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
});
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
taskId: "retry-task",
kind: "jira-dispatch",
meta: {
...task.meta,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
notifyHandler: true,
});
});
it("surfaces task watcher errors without offering an unsafe retry", () => {
// Given
const task = {
...buildTask(undefined),
status: "error",
error: "Tracking the task failed unexpectedly. Try again later.",
} as WatchedTask;
// When
jiraDispatchTaskHandler.onError(task);
// Then
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Jira dispatch failed",
description: "Tracking the task failed unexpectedly. Try again later.",
});
});
});
@@ -0,0 +1,105 @@
"use client";
import { toast, ToastAction } from "@/components/shadcn/toast";
import {
executeJiraDispatchBatches,
getJiraRetryBatch,
} from "@/lib/jira-dispatch-execution";
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import { parseJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store";
import {
JIRA_DISPATCH_MODE,
type JiraDispatchTaskResult,
} from "@/types/integrations";
const retryFailedFindings = async (
task: WatchedTask,
failedFindingIds: string[],
): Promise<void> => {
const meta = parseJiraDispatchTaskMeta(task);
if (!meta) {
toast({
variant: "destructive",
title: "Jira retry failed",
description: "The original Jira dispatch configuration is unavailable.",
});
return;
}
const retryBatch = getJiraRetryBatch(failedFindingIds);
if (!retryBatch) return;
try {
toast({
title: "Retry started",
description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`,
});
const result = await executeJiraDispatchBatches(
[retryBatch],
{
integrationId: meta.integrationId,
projectKey: meta.projectKey,
issueType: meta.issueType,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
{ notifyHandler: true },
);
if (result.startedTaskCount === 0 && result.errors.length > 0) {
toast({
variant: "destructive",
title: "Jira retry failed",
description: result.errors.join(" "),
});
}
} catch {
toast({
variant: "destructive",
title: "Jira retry failed",
description: "The retry could not be started. Try again later.",
});
}
};
const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) =>
failedFindingIds?.length ? (
<ToastAction
altText="Retry failed Findings"
onClick={() => retryFailedFindings(task, failedFindingIds)}
>
Retry failed
</ToastAction>
) : undefined;
export const jiraDispatchTaskHandler: TaskKindHandler = {
onReady: (task) => {
const outcome = evaluateJiraDispatchTask(
"completed",
task.result as JiraDispatchTaskResult | undefined,
);
if (!outcome.success) {
toast({
variant: "destructive",
title: "Jira dispatch failed",
description: outcome.error,
action: buildRetryAction(task, outcome.failedFindingIds),
});
return;
}
toast({
title: outcome.warning ? "Jira dispatch partially completed" : "Success!",
description: outcome.warning ?? outcome.message,
action: buildRetryAction(task, outcome.failedFindingIds),
});
},
onError: (task) => {
toast({
variant: "destructive",
title: "Jira dispatch failed",
description: task.error || "The Jira dispatch task failed unexpectedly.",
});
},
};
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
describe("buildJiraDispatchChoiceCopy", () => {
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: true,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected Findings from this Finding Group.",
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
it("preserves resource copy for resource-based grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected affected failing resources.",
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
});
});
it("uses neutral Findings copy outside a single Finding Group", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
}),
).toEqual({
description: "Create Jira issue(s) for 2 selected Findings.",
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
});
@@ -0,0 +1,59 @@
export const JIRA_SELECTION_KIND = {
FINDINGS: "findings",
RESOURCES: "resources",
} as const;
type JiraSelectionKind =
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
interface JiraDispatchChoiceCopyParams {
selectedCount: number;
isSelectedFindingGroupFlow: boolean;
selectionKind?: JiraSelectionKind;
}
interface JiraDispatchChoiceCopy {
description: string;
groupedTitle: string;
groupedHelp: string;
individualHelp: string;
}
export const buildJiraDispatchChoiceCopy = ({
selectedCount,
isSelectedFindingGroupFlow,
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
if (isSelectedFindingGroupFlow) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`,
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
return {
description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`,
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
};
};
@@ -0,0 +1,293 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createJiraBatchSelection } from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { SendToJiraModal } from "./send-to-jira-modal";
interface ToastActionMockProps extends ComponentProps<"button"> {
altText: string;
}
const {
executeJiraDispatchBatchesMock,
getJiraIntegrationsMock,
getJiraIssueTypesMock,
toastMock,
} = vi.hoisted(() => ({
executeJiraDispatchBatchesMock: vi.fn(),
getJiraIntegrationsMock: vi.fn(),
getJiraIssueTypesMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/integrations/jira-dispatch", () => ({
getJiraIntegrations: getJiraIntegrationsMock,
getJiraIssueTypes: getJiraIssueTypesMock,
}));
vi.mock("@/lib/jira-dispatch-execution", () => ({
executeJiraDispatchBatches: executeJiraDispatchBatchesMock,
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
ToastAction: ({
altText: _altText,
children,
...props
}: ToastActionMockProps) => <button {...props}>{children}</button>,
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: ({
options,
onValueChange,
placeholder,
disabled,
}: {
options: { value: string; label: string }[];
onValueChange: (values: string[]) => void;
placeholder: string;
disabled?: boolean;
}) => (
<button
type="button"
disabled={disabled}
onClick={() => onValueChange([options[0]?.value ?? ""])}
>
{placeholder}
</button>
),
}));
const selection = createJiraBatchSelection([
{
targetIds: ["check-a"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: ["finding-1", "finding-2"],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
},
])!;
describe("SendToJiraModal", () => {
beforeEach(() => {
vi.clearAllMocks();
getJiraIntegrationsMock.mockResolvedValue({
success: true,
data: [
{
type: "integrations",
id: "jira-1",
attributes: {
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: null,
integration_type: "jira",
configuration: {
domain: "example.atlassian.net",
projects: { SEC: "Security" },
issue_types: { SEC: ["Task"] },
},
},
links: { self: "/integrations/jira-1" },
},
],
});
getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] });
executeJiraDispatchBatchesMock.mockResolvedValue({
startedTaskCount: 2,
successfulTaskCount: 2,
successfulIssueCount: 3,
successMessage: "3 Jira issues were created or updated successfully.",
warnings: [],
errors: [],
});
});
it("renders the dispatch choice and custom mixed-selection description", async () => {
// Given / When
render(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
description="Create Jira issues for 1 Group and 2 Findings."
/>,
);
// Then
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
expect(
screen.getByText("Create one Jira issue for all selected Findings"),
).toBeInTheDocument();
expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument();
expect(
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
).toBeInTheDocument();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("delegates mixed dispatch execution with the selected settings", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
await user.click(
screen.getByRole("radio", { name: "Create separate Jira issues" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(executeJiraDispatchBatchesMock).toHaveBeenCalledWith(
[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
],
{
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
},
),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("retries only the failed Jira dispatch batch", async () => {
// Given
const user = userEvent.setup();
const retryBatch = {
targetIds: ["finding-2"],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
} as const;
executeJiraDispatchBatchesMock
.mockResolvedValueOnce({
startedTaskCount: 1,
successfulTaskCount: 1,
successfulIssueCount: 1,
successMessage: "1 Jira issue was created successfully.",
warnings: ["1 Jira issue failed."],
errors: [],
retryBatch,
})
.mockResolvedValueOnce({
startedTaskCount: 1,
successfulTaskCount: 1,
successfulIssueCount: 1,
successMessage: "1 Jira issue was created successfully.",
warnings: [],
errors: [],
});
const onOpenChange = vi.fn();
const { rerender } = render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
await waitFor(() =>
expect(executeJiraDispatchBatchesMock).toHaveBeenCalledTimes(1),
);
const partialToast = toastMock.mock.calls.find(
([toast]) => toast.title === "Partial success",
)?.[0];
rerender(
<SendToJiraModal
isOpen={false}
onOpenChange={onOpenChange}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
/>,
);
render(partialToast.action);
// When
await user.click(screen.getByRole("button", { name: "Retry failed" }));
// Then
await waitFor(() =>
expect(executeJiraDispatchBatchesMock).toHaveBeenNthCalledWith(
2,
[retryBatch],
{
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
),
);
});
it("closes the modal before navigating to Jira configuration", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
getJiraIntegrationsMock.mockResolvedValueOnce({
success: true,
data: [],
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={selection}
/>,
);
const configureLink = await screen.findByRole("link", {
name: "Configure",
});
// When
await user.click(configureLink);
// Then
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
Loaded 100 of 568 files, more files were not shown because too many files have changed in this diff. Show more