mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-27 22:04:20 +00:00
chore(ui): merge master into expired-session fix
This commit is contained in:
568 files changed
+23697
-2930
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+58
-21
@@ -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 ({
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
+1
@@ -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", () => {
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
+4
-6
@@ -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 (
|
||||
|
||||
+2
-4
@@ -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";
|
||||
|
||||
+4
-4
@@ -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", () => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -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}
|
||||
+71
-20
@@ -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
Reference in new issue
Block a user