diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index de17ac4a37..d1d734ea66 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -1,3 +1,5 @@ +import { ChevronDownIcon } from "lucide-react"; +import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; import { @@ -24,13 +26,17 @@ import { TopFailedSectionsCardSkeleton, } from "@/components/compliance"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Button } from "@/components/shadcn/button/button"; +import { Card } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/shadcn/content-layout"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { getReportTypeForCompliance, pickLatestCisPerProvider, } from "@/lib/compliance/compliance-report-types"; +import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; +import type { SearchParamsProps } from "@/types"; import { AttributesData, Framework, @@ -38,38 +44,84 @@ import { } from "@/types/compliance"; import { ScanEntity } from "@/types/scans"; -interface ComplianceDetailSearchParams { - complianceId: string; - version?: string; - scanId?: string; - section?: string; - "filter[region__in]"?: string; - "filter[cis_profile_level]"?: string; - page?: string; - pageSize?: string; -} +import { CrossProviderDetail } from "../_components/cross-provider-detail"; +import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks"; +import { buildSearchParamsKey } from "../_lib/search-params-key"; + +const getSingleSearchParam = ( + value: string | string[] | undefined, +): string | undefined => + typeof value === "string" && value ? value : undefined; export default async function ComplianceDetail({ params, searchParams, }: { params: Promise<{ compliancetitle: string }>; - searchParams: Promise; + searchParams: Promise; }) { const { compliancetitle } = await params; const resolvedSearchParams = await searchParams; - const { complianceId, version, scanId, section } = resolvedSearchParams; - const regionFilter = resolvedSearchParams["filter[region__in]"]; - const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"]; + const complianceId = getSingleSearchParam(resolvedSearchParams.complianceId); + const version = getSingleSearchParam(resolvedSearchParams.version); + const scanId = getSingleSearchParam(resolvedSearchParams.scanId); + const section = getSingleSearchParam(resolvedSearchParams.section); + const mode = getSingleSearchParam(resolvedSearchParams.mode); + + if (!complianceId) { + notFound(); + } + + // Cross-provider mode replaces the per-scan pipeline with the universal + // roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so + // the route is blocked in OSS the same way the compliance tab is. + if (mode === "cross-provider") { + if (!isCloud()) { + redirect("/compliance"); + } + + const framework = resolveCrossProviderFramework( + complianceId, + compliancetitle, + ); + if (!framework) { + notFound(); + } + + const crossProviderTitle = framework.title.split("-").join(" "); + return ( + + +
+ + +
+ + + } + > + +
+
+ ); + } + const regionFilter = getSingleSearchParam( + resolvedSearchParams["filter[region__in]"], + ); + const cisProfileFilter = getSingleSearchParam( + resolvedSearchParams["filter[cis_profile_level]"], + ); const logoPath = getComplianceIcon(compliancetitle); - // Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination - const paramsForKey = Object.fromEntries( - Object.entries(resolvedSearchParams).filter( - ([key]) => key !== "page" && key !== "pageSize", - ), - ); - const searchParamsKey = JSON.stringify(paramsForKey); + const searchParamsKey = buildSearchParamsKey(resolvedSearchParams); const formattedTitle = compliancetitle.split("-").join(" "); const pageTitle = version @@ -176,33 +228,45 @@ export default async function ComplianceDetail({ return ( -
-
- -
- {selectedScanId && ( -
- +
+
+
- )} -
+ {selectedScanId && ( +
+ + Report + + + } + reportType={getReportTypeForCompliance( + attributesData?.data?.[0]?.attributes?.framework, + complianceId, + latestCisIds.has(complianceId), + )} + /> +
+ )} +
+ */}
-
({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + captureExceptionMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), + GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.", +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, +})); + +import { + generateCrossProviderPdf, + getCrossProviderComplianceOverview, + getCrossProviderPdfBinary, + getLatestCrossProviderPdf, +} from "./cross-provider"; + +const lastFetchUrl = (): URL => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + return new URL(String(call[0])); +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue(jsonResponse({ data: null })); + getAuthHeadersMock.mockResolvedValue({ + Accept: "application/vnd.api+json", + Authorization: "Bearer test-token", + }); + handleApiResponseMock.mockResolvedValue({ data: null }); +}); + +describe("getCrossProviderComplianceOverview", () => { + it("requests the overview with the required compliance_id filter", async () => { + await getCrossProviderComplianceOverview({ complianceId: "csa_ccm_4.0" }); + + const url = lastFetchUrl(); + expect(url.pathname).toBe("/api/v1/cross-provider-compliance-overviews"); + expect(url.searchParams.get("filter[compliance_id]")).toBe("csa_ccm_4.0"); + expect(Array.from(url.searchParams.keys())).toEqual([ + "filter[compliance_id]", + ]); + }); + + it("serializes optional filters and joins scan ids with commas", async () => { + await getCrossProviderComplianceOverview({ + complianceId: "dora_2022_2554", + filters: { + scanIds: ["scan-1", "scan-2"], + providerTypes: "aws,gcp", + providerIds: "prov-1", + providerGroups: "group-1", + }, + }); + + const url = lastFetchUrl(); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe("prov-1"); + expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1"); + expect(url.searchParams.has("filter[region__in]")).toBe(false); + }); + + it("wraps successful overview responses", async () => { + const payload = { data: { id: "csa_ccm_4.0" } }; + handleApiResponseMock.mockResolvedValue(payload); + + const result = await getCrossProviderComplianceOverview({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toEqual({ status: "success", response: payload }); + }); + + it("returns the shared action error shape for payment-required responses", async () => { + fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 402)); + handleApiResponseMock.mockResolvedValue({ + error: "Payment required.", + status: 402, + }); + + const result = await getCrossProviderComplianceOverview({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toEqual({ + status: "action-error", + result: { error: "Payment required.", status: 402 }, + }); + }); + + it("returns a load error when the network call throws", async () => { + fetchMock.mockRejectedValue(new Error("boom")); + + const result = await getCrossProviderComplianceOverview({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toEqual({ + status: "load-error", + message: + "Could not load cross-provider compliance data. Try again later.", + }); + }); +}); + +describe("generateCrossProviderPdf", () => { + it("POSTs pdf with filters and an optional report name", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ); + + const result = await generateCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + filters: { scanIds: ["scan-1"], providerTypes: "aws" }, + reportName: "quarterly.pdf", + }); + + expect(result).toEqual({ taskId: "task-1" }); + const call = fetchMock.mock.calls.at(-1); + expect((call?.[1] as RequestInit).method).toBe("POST"); + const url = lastFetchUrl(); + expect(url.pathname).toBe( + "/api/v1/cross-provider-compliance-overviews/pdf", + ); + expect(url.searchParams.get("filter[compliance_id]")).toBe("csa_ccm_4.0"); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws"); + expect(url.searchParams.get("report_name")).toBe("quarterly.pdf"); + expect(url.searchParams.has("only_failed")).toBe(false); + expect(url.searchParams.has("include_manual")).toBe(false); + }); + + it("omits the optional report name when not provided", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ); + + await generateCrossProviderPdf({ complianceId: "csa_ccm_4.0" }); + + const url = lastFetchUrl(); + expect(url.searchParams.has("report_name")).toBe(false); + }); + + it("returns the API error detail when generation fails", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ errors: [{ detail: "No compatible scans." }] }, 422), + ); + + const result = await generateCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toEqual({ error: "No compatible scans." }); + }); + + it("reports 5xx failures to Sentry with the static route template only", async () => { + fetchMock.mockResolvedValue(jsonResponse({}, 500)); + + const result = await generateCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + reportName: "secret-name.pdf", + }); + + expect(result).toEqual({ error: "Generic server error." }); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]); + expect(serialized).toContain("cross-provider-compliance-overviews/pdf"); + expect(serialized).not.toContain("secret-name"); + }); + + it("falls back to the static message for text/html error pages", async () => { + // A proxy/gateway error page must not be parsed as JSON. + fetchMock.mockResolvedValue( + new Response("Bad Gateway", { + status: 422, + headers: { "Content-Type": "text/html" }, + }), + ); + + const result = await generateCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toEqual({ + error: + "Unable to start PDF generation. Contact support if the issue continues.", + }); + }); +}); + +describe("getCrossProviderPdfBinary", () => { + it("returns pending state on 202", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { data: { id: "task-1", attributes: { state: "executing" } } }, + 202, + ), + ); + + const result = await getCrossProviderPdfBinary("task-1"); + + expect(result).toEqual({ + pending: true, + state: "executing", + taskId: "task-1", + }); + expect(lastFetchUrl().pathname).toBe( + "/api/v1/cross-provider-compliance-overviews/pdf/task-1", + ); + }); + + it("returns the binary as base64 with the content-disposition filename", async () => { + fetchMock.mockResolvedValue( + new Response(Buffer.from("pdf-bytes"), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": 'attachment; filename="csa-report.pdf"', + }, + }), + ); + + const result = await getCrossProviderPdfBinary("task-1"); + + expect(result).toEqual({ + success: true, + data: Buffer.from("pdf-bytes").toString("base64"), + filename: "csa-report.pdf", + }); + }); + + it("rejects task ids that could smuggle path segments, without fetching", async () => { + const result = await getCrossProviderPdfBinary("../../../etc/passwd"); + + expect(result).toEqual({ error: "Invalid task identifier." }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns the API error detail on failure", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ errors: [{ detail: "Report expired." }] }, 410), + ); + + const result = await getCrossProviderPdfBinary("task-1"); + + expect(result).toEqual({ error: "Report expired." }); + }); + + it("reports 5xx failures to Sentry", async () => { + fetchMock.mockResolvedValue(jsonResponse({}, 500)); + + const result = await getCrossProviderPdfBinary("task-1"); + + expect(result).toEqual({ error: "Generic server error." }); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]); + expect(serialized).toContain("pdf/{taskId}"); + }); +}); + +describe("getLatestCrossProviderPdf", () => { + it("returns the report descriptor when a matching report exists", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + data: { + type: "tasks", + id: "task-9", + attributes: { + completed_at: "2026-07-01T10:00:00Z", + result: { filename: "csa-latest.pdf" }, + }, + }, + }), + ); + + const result = await getLatestCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + filters: { scanIds: ["scan-1"] }, + }); + + expect(result).toEqual({ + taskId: "task-9", + filename: "csa-latest.pdf", + completedAt: "2026-07-01T10:00:00Z", + }); + const url = lastFetchUrl(); + expect(url.pathname).toBe( + "/api/v1/cross-provider-compliance-overviews/pdf/latest", + ); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1"); + }); + + it("returns null when no report has been generated yet (404)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 404)); + + const result = await getLatestCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toBeNull(); + }); + + it("degrades to null on 5xx but still reports to Sentry", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + fetchMock.mockResolvedValue(jsonResponse({}, 500)); + + const result = await getLatestCrossProviderPdf({ + complianceId: "csa_ccm_4.0", + }); + + expect(result).toBeNull(); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]); + expect(serialized).toContain("pdf/latest"); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.ts new file mode 100644 index 0000000000..eacdc29fde --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.ts @@ -0,0 +1,315 @@ +"use server"; + +import * as Sentry from "@sentry/nextjs"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; +import { hasActionError } from "@/lib/action-errors"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { SentryErrorSource, SentryErrorType } from "@/sentry"; + +import type { + CrossProviderApiFilters, + CrossProviderOverviewResponse, + CrossProviderOverviewResult, + LatestCrossProviderPdf, +} from "../_types"; +import { + CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, +} from "../_types"; + +const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews"; + +/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */ +interface PdfEndpointErrorBody { + errors?: Array<{ detail?: string }>; + error?: string; + message?: string; +} + +/** + * Extracts a user-safe message from a failed PDF endpoint response and, for + * unexpected failures, reports it to Sentry. `operation` must be a STATIC + * route template (e.g. `GET .../pdf/{taskId}`) — never the + * resolved URL, which would carry the task id or a user-typed report name. + */ +const getPdfEndpointErrorMessage = async ( + response: Response, + fallbackMessage: string, + operation: string, +): Promise => { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + const errorData: PdfEndpointErrorBody | null = contentType.includes( + "text/html", + ) + ? null + : await response.json().catch(() => null); + + // These endpoints bypass handleApiResponse (binary/task protocol), so + // server failures would otherwise go unmonitored. + if (response.status >= 500) { + Sentry.captureException( + new Error( + `Cross-provider PDF request failed (${response.status}) at ${operation}`, + ), + { + tags: { + api_error: true, + status_code: response.status.toString(), + error_type: SentryErrorType.SERVER_ERROR, + error_source: SentryErrorSource.SERVER_ACTION, + }, + level: "error", + contexts: { + api_response: { + status: response.status, + statusText: response.statusText, + operation, + }, + }, + }, + ); + return GENERIC_SERVER_ERROR_MESSAGE; + } + + return ( + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + fallbackMessage + ); +}; + +/** Appends the shared cross-provider filter params to a request URL. */ +const applyFilters = (url: URL, filters?: CrossProviderApiFilters) => { + if (!filters) return; + + if (filters.scanIds && filters.scanIds.length > 0) { + url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); + } + + const paramMap = { + "filter[provider_type__in]": filters.providerTypes, + "filter[provider_id__in]": filters.providerIds, + "filter[provider_groups__in]": filters.providerGroups, + }; + for (const [key, value] of Object.entries(paramMap)) { + if (value && value.trim().length > 0) { + url.searchParams.set(key, value); + } + } +}; + +/** + * Aggregate a universal compliance framework across one scan per compatible + * provider (Prowler Cloud only — the OSS API has no such endpoint). + * + * When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED + * scan per compatible provider. Non-2xx responses are returned as structured + * action errors so callers can reuse the app-wide 402/403 handlers. + */ +export const getCrossProviderComplianceOverview = async ({ + complianceId, + filters, +}: { + complianceId: string; + filters?: CrossProviderApiFilters; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}`); + url.searchParams.set("filter[compliance_id]", complianceId); + applyFilters(url, filters); + + try { + const response = await fetch(url.toString(), { headers }); + const responseData = await handleApiResponse(response); + + if (hasActionError(responseData)) { + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, + result: responseData, + }; + } + + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, + response: responseData as CrossProviderOverviewResponse, + }; + } catch (error) { + console.error("Error fetching cross-provider compliance overview:", error); + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + }; + } +}; + +/** + * Trigger ad-hoc generation of the combined cross-provider compliance PDF. + * + * The PDF is built asynchronously by a backend task: this returns the task id + * so the caller can poll it and then download via + * {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on + * screen (`attributes.scan_ids`) so the report matches the displayed data + * instead of re-resolving "latest scan per provider", which could race a scan + * completing in between. + */ +export const generateCrossProviderPdf = async ({ + complianceId, + filters, + reportName, +}: { + complianceId: string; + filters?: CrossProviderApiFilters; + /** Optional download filename; sanitized server-side. */ + reportName?: string; +}): Promise<{ taskId: string } | { error: string }> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf`); + url.searchParams.set("filter[compliance_id]", complianceId); + applyFilters(url, filters); + if (reportName) url.searchParams.set("report_name", reportName); + + try { + const response = await fetch(url.toString(), { method: "POST", headers }); + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to start PDF generation. Contact support if the issue continues.", + `POST ${CROSS_PROVIDER_API_PATH}/pdf`, + ), + ); + } + + const json = await response.json(); + const taskId = json?.data?.id; + if (!taskId) { + throw new Error("Unexpected response starting PDF generation."); + } + + return { taskId }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +/** + * Fetch the finished cross-provider PDF for a task started by + * {@link generateCrossProviderPdf}. Speaks the same 202-pending / + * 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it + * returns the shared {@link ScanBinaryResult} shape and callers can reuse the + * existing download plumbing unchanged. + */ +export const getCrossProviderPdfBinary = async ( + taskId: string, +): Promise => { + // The task id reaches the URL path: constrain it to the task-id charset + // (UUIDs) so a crafted value cannot smuggle `/`, `..` or a host. + const safeTaskId = taskId.trim(); + if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) { + return { error: "Invalid task identifier." }; + } + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`, + ); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + return { + pending: true, + state: json?.data?.attributes?.state, + taskId: json?.data?.id, + }; + } + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to retrieve the compliance PDF report. Contact support if the issue continues.", + `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`, + ), + ); + } + + const contentDisposition = + response.headers.get("content-disposition") || ""; + const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + const filename = filenameMatch?.[1] || "cross-provider-compliance.pdf"; + + const arrayBuffer = await response.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + + return { success: true, data: base64, filename }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +/** + * Check whether a cross-provider PDF already exists for the given filters so + * the UI can offer "Download" immediately instead of forcing a re-generate. + * + * 404 means "not generated yet" — a normal state, returned as `null` rather + * than an error. The backend only matches reports built from the exact scan + * set the filters resolve to, so a report goes stale (→ `null`) as soon as a + * contributing provider completes a new scan. Failures also degrade to + * `null`: this is an optional availability check and the caller's fallback + * (show "Generate") is always safe. + */ +export const getLatestCrossProviderPdf = async ({ + complianceId, + filters, +}: { + complianceId: string; + filters?: CrossProviderApiFilters; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/latest`); + url.searchParams.set("filter[compliance_id]", complianceId); + applyFilters(url, filters); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 404) return null; + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to check for an existing PDF report.", + `GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`, + ), + ); + } + + const json = await response.json(); + const taskId = json?.data?.id; + if (!taskId) return null; + + return { + taskId, + filename: json?.data?.attributes?.result?.filename, + completedAt: json?.data?.attributes?.completed_at, + }; + } catch (error) { + // Degraded on purpose, but logged: without this a systematically failing + // endpoint would be indistinguishable from "never generated". + console.error("Error checking for an existing cross-provider PDF:", error); + return null; + } +}; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts new file mode 100644 index 0000000000..7cb6b8cb39 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts @@ -0,0 +1,17 @@ +import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; + +function isComplianceTab(value: string): value is ComplianceTab { + return Object.values(COMPLIANCE_TAB).includes(value as ComplianceTab); +} + +/** Resolves `?tab=` into a valid tab, defaulting to Per Scan so existing + * bookmarks (no query param) keep working. */ +function getComplianceTab(value: string | string[] | undefined): ComplianceTab { + if (typeof value !== "string") { + return COMPLIANCE_TAB.PER_SCAN; + } + + return isComplianceTab(value) ? value : COMPLIANCE_TAB.PER_SCAN; +} + +export { getComplianceTab }; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx new file mode 100644 index 0000000000..b5f25adfe7 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { COMPLIANCE_TAB } from "../_types"; +import { CompliancePageTabs } from "./compliance-page-tabs"; +import { getComplianceTab } from "./compliance-page-tabs.shared"; + +const { pushMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +describe("getComplianceTab", () => { + it("falls back to per-scan for missing or invalid values", () => { + expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.PER_SCAN); + expect(getComplianceTab(["cross-provider"])).toBe(COMPLIANCE_TAB.PER_SCAN); + expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.PER_SCAN); + expect(getComplianceTab("cross-provider")).toBe( + COMPLIANCE_TAB.CROSS_PROVIDER, + ); + }); +}); + +describe("CompliancePageTabs", () => { + beforeEach(() => { + pushMock.mockClear(); + }); + + it("navigates with ?tab=cross-provider and back to the bare route", async () => { + const user = userEvent.setup(); + const { rerender } = render( + Per scan content
} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /cross-provider/i })); + expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider"); + + rerender( + Per scan content} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /per scan/i })); + expect(pushMock).toHaveBeenCalledWith("/compliance"); + }); + + it("disables the cross-provider tab with the cloud upsell badge in OSS", () => { + render( + Per scan content} + crossProviderContent={null} + />, + ); + + const crossProviderTab = screen.getByRole("tab", { + name: /cross-provider/i, + }); + const tabLabel = screen.getByText("Cross-Provider", { exact: true }); + const cloudBadge = screen.getByText("Available in Prowler Cloud"); + + expect(crossProviderTab).toBeDisabled(); + expect(crossProviderTab).not.toHaveClass("disabled:opacity-50"); + expect(tabLabel).toHaveClass("opacity-50"); + expect(cloudBadge.parentElement).toHaveClass("gap-2"); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx new file mode 100644 index 0000000000..fa6ab6ea70 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { ReactNode } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn"; +import { CloudFeatureBadge } from "@/components/shared/cloud-feature-badge"; + +import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; + +interface CompliancePageTabsProps { + activeTab: ComplianceTab; + /** False in OSS: the Cross-Provider tab renders disabled with the + * "Available in Prowler Cloud" upsell badge. */ + crossProviderEnabled: boolean; + perScanContent: ReactNode; + crossProviderContent: ReactNode; +} + +export const CompliancePageTabs = ({ + activeTab, + crossProviderEnabled, + perScanContent, + crossProviderContent, +}: CompliancePageTabsProps) => { + const router = useRouter(); + + const handleTabChange = (tab: string) => { + const typedTab = tab as ComplianceTab; + + if (typedTab === activeTab) { + return; + } + + // Per Scan renders without the query param so existing bookmarks and + // shared links keep resolving to the default view. + if (typedTab === COMPLIANCE_TAB.PER_SCAN) { + router.push("/compliance"); + } else { + router.push(`/compliance?tab=${typedTab}`); + } + }; + + return ( + // Same layout spacing as the scans view tabs (scans-page-shell.tsx). + + + Per Scan + : undefined} + > + Cross-Provider + + + + + {perScanContent} + + + {crossProviderContent} + + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx new file mode 100644 index 0000000000..7972379b1f --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -0,0 +1,241 @@ +import { Info } from "lucide-react"; +import Image from "next/image"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { + ClientAccordionWrapper, + RequirementsStatusCard, + TopFailedSectionsCard, +} from "@/components/compliance"; +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Card } from "@/components/shadcn/card/card"; +import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import type { Framework, RequirementsTotals } from "@/types/compliance"; + +import { + getCrossProviderComplianceOverview, + getLatestCrossProviderPdf, +} from "../_actions/cross-provider"; +import { toCrossProviderAccordionItems } from "../_lib/cross-provider-accordion"; +import { + buildRequirementExtrasMap, + computeProviderBreakdown, + crossProviderToMapperInput, +} from "../_lib/cross-provider-adapter"; +import { + CROSS_PROVIDER_FRAMEWORKS, + parseCrossProviderFilters, +} from "../_lib/cross-provider-frameworks"; +import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; +import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; +import type { + CrossProviderAccountOption, + CrossProviderGroupOption, +} from "./cross-provider-filters"; +import { CrossProviderFilters } from "./cross-provider-filters"; +import { CrossProviderHubLink } from "./cross-provider-hub-link"; +import { CrossProviderPdfButton } from "./cross-provider-pdf-button"; +import { ProviderCoverageCard } from "./provider-coverage-card"; + +interface CrossProviderDetailProps { + compliancetitle: string; + complianceId: string; + searchParams: Record; + targetSection?: string; +} + +/** + * Server island for the cross-provider detail (`?mode=cross-provider`): + * fetches the roll-up, funnels it through the real framework mapper via the + * adapter, and renders the same summary-charts + accordion layout as the + * per-scan detail with per-provider augmentations. + */ +export const CrossProviderDetail = async ({ + compliancetitle, + complianceId, + searchParams, + targetSection, +}: CrossProviderDetailProps) => { + const filters = parseCrossProviderFilters(searchParams); + + const [overviewResponse, providersData, providerGroupsData] = + await Promise.all([ + getCrossProviderComplianceOverview({ complianceId, filters }), + getAllProviders(), + getAllProviderGroups(), + ]); + + if ( + overviewResponse.status === + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR + ) { + return ; + } + + if ( + overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR + ) { + return ; + } + + const overviewData = overviewResponse.response.data; + + if (!overviewData?.attributes) { + return ( + + + + No cross-provider compliance data was returned for this framework. + Universal frameworks aggregate the latest completed scan of every + compatible provider — run a scan to populate this view. + + + ); + } + + const attrs = overviewData.attributes; + + // Scoped to the EXACT scans the overview resolved (not the raw filters), so + // an offered "Download latest" always matches the data on screen even if a + // provider finished a new scan between the two calls. The overview + // aggregation dominates wall-clock; serializing this quick check is cheap. + const latestPdf = await getLatestCrossProviderPdf({ + complianceId, + filters: { ...filters, scanIds: attrs.scan_ids }, + }); + + const mapper = getComplianceMapper(attrs.framework); + const { attributesData, requirementsData } = + crossProviderToMapperInput(attrs); + const data = mapper.mapComplianceData(attributesData, requirementsData); + const extras = buildRequirementExtrasMap(attrs); + const providerBreakdown = computeProviderBreakdown(attrs); + + const totals: RequirementsTotals = data.reduce( + (acc: RequirementsTotals, framework: Framework) => ({ + pass: acc.pass + framework.pass, + fail: acc.fail + framework.fail, + manual: acc.manual + framework.manual, + }), + { pass: 0, fail: 0, manual: 0 }, + ); + const accordionItems = toCrossProviderAccordionItems( + data, + extras, + attrs.framework, + ); + const topFailedResult = mapper.getTopFailedSections(data); + + // Same `${framework.name}-${category.name}` key scheme as the per-scan + // detail, so ?section= deep links (e.g. from Top Failed Sections) work. + const initialExpandedKeys: string[] = []; + if (targetSection) { + const candidates = new Set( + data.map((framework: Framework) => `${framework.name}-${targetSection}`), + ); + const match = accordionItems.find((item) => candidates.has(item.key)); + if (match) { + initialExpandedKeys.push(match.key); + } + } + + const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find( + (entry) => entry.complianceId === complianceId, + ); + const compatibleTypes = + catalogEntry?.compatibleProviders ?? + providerBreakdown.map((b) => b.provider); + const logoPath = getComplianceIcon(compliancetitle); + + const providerAccounts: CrossProviderAccountOption[] = ( + providersData?.data || [] + ) + .filter((provider) => + compatibleTypes.some((type) => type === provider.attributes.provider), + ) + .map((provider) => ({ + id: provider.id, + label: provider.attributes.alias + ? `${provider.attributes.alias} (${provider.attributes.uid})` + : provider.attributes.uid, + type: provider.attributes.provider, + })); + + const providerGroups: CrossProviderGroupOption[] = ( + providerGroupsData?.data || [] + ).map((group) => ({ id: group.id, name: group.attributes.name })); + + return ( +
+ {/* Header card — same structure as the per-scan detail: identity row + (logo + context) with the report action top-right, filters below + (lighthouse-settings card pattern). */} + +
+
+
+ {logoPath && ( +
+ {`${compliancetitle} +
+ )} +
+
+ + {attrs.name || compliancetitle.split("-").join(" ")} + + +
+

+ {attrs.providers.length} of {compatibleTypes.length}{" "} + compatible providers scanned · {attrs.scan_ids.length}{" "} + {attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated +

+
+
+
+ +
+
+ + +
+
+ +
+ + + +
+ + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-error-alert.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-error-alert.tsx new file mode 100644 index 0000000000..d692ea14b9 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-error-alert.tsx @@ -0,0 +1,38 @@ +import { AlertTriangle } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; +import { + ACTION_ERROR_STATUS, + type ActionErrorResult, + getActionErrorMessage, +} from "@/lib/action-errors"; + +import { CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE } from "../_types"; + +interface CrossProviderErrorAlertProps { + result?: ActionErrorResult; + message?: string; +} + +export const CrossProviderErrorAlert = ({ + result, + message = CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, +}: CrossProviderErrorAlertProps) => { + const isUsageLimit = result?.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED; + + return ( + + + + {isUsageLimit ? ( + + ) : result ? ( + getActionErrorMessage(result, { fallback: message }) + ) : ( + message + )} + + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-filters.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-filters.test.tsx new file mode 100644 index 0000000000..6c27fbed74 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-filters.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { CrossProviderFilters } from "./cross-provider-filters"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ updateFilter: vi.fn() }), +})); + +vi.mock("@/components/filters/clear-filters-button", () => ({ + ClearFiltersButton: () => , +})); + +vi.mock("@/components/shadcn/select/multiselect", () => ({ + MultiSelect: ({ children }: { children: ReactNode }) =>
{children}
, + MultiSelectTrigger: ({ + children, + ...props + }: { + children: ReactNode; + "aria-label"?: string; + }) => ( + + ), + MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( + {placeholder} + ), + MultiSelectContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + MultiSelectItem: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + MultiSelectSelectAll: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + MultiSelectSeparator: () =>
, +})); + +describe("CrossProviderFilters", () => { + it("shows only the three cross-provider filters with product copy", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("combobox", { name: "Provider type" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: "Providers" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: "Provider group" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("combobox")).toHaveLength(3); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-filters.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-filters.tsx new file mode 100644 index 0000000000..44c477837b --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-filters.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import { + type KnownProviderType, + PROVIDER_DISPLAY_NAMES, + type ProviderType, +} from "@/types/providers"; + +export interface CrossProviderAccountOption { + id: string; + label: string; + type: ProviderType; +} + +export interface CrossProviderGroupOption { + id: string; + name: string; +} + +interface CrossProviderFiltersProps { + /** Provider types offered by the visible universal frameworks. */ + providerTypes: readonly KnownProviderType[]; + providerAccounts: CrossProviderAccountOption[]; + providerGroups: CrossProviderGroupOption[]; +} + +interface UrlMultiSelectOption { + value: string; + label: string; +} + +interface UrlMultiSelectProps { + filterKey: string; + placeholder: string; + options: UrlMultiSelectOption[]; +} + +const UrlMultiSelect = ({ + filterKey, + placeholder, + options, +}: UrlMultiSelectProps) => { + const searchParams = useSearchParams(); + const { updateFilter } = useUrlFilters(); + + const values = + searchParams.get(`filter[${filterKey}]`)?.split(",").filter(Boolean) ?? []; + + return ( +
+ updateFilter(filterKey, nextValues)} + > + + + + 8} width="wide"> + Select All + + {options.map((option) => ( + + {option.label} + + ))} + + +
+ ); +}; + +export const CrossProviderFilters = ({ + providerTypes, + providerAccounts, + providerGroups, +}: CrossProviderFiltersProps) => { + return ( +
+ ({ + value: type, + label: PROVIDER_DISPLAY_NAMES[type], + }))} + /> + ({ + value: account.id, + label: account.label, + }))} + /> + ({ + value: group.id, + label: group.name, + }))} + /> + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx new file mode 100644 index 0000000000..65b87806e1 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { Progress } from "@/components/shadcn/progress"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { + getScoreIndicatorClass, + type ScoreColorVariant, +} from "@/lib/compliance/score-utils"; +import { cn } from "@/lib/utils"; +import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; + +import { buildCrossProviderDetailHref } from "../_lib/cross-provider-frameworks"; +import type { CrossProviderFrameworkSummary } from "../_types"; + +export const CrossProviderFrameworkCard = ({ + complianceId, + title, + version, + description, + requirementsPassed, + requirementsFailed, + requirementsManual, + totalRequirements, + providerBreakdown, +}: CrossProviderFrameworkSummary) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`; + + const ratingPercentage = + totalRequirements > 0 + ? Math.floor((requirementsPassed / totalRequirements) * 100) + : 0; + + // Same thresholds as the per-scan ComplianceCard. + const getRatingVariant = (value: number): ScoreColorVariant => { + if (value <= 10) return "danger"; + if (value <= 40) return "warning"; + return "success"; + }; + + const navigateToDetail = () => { + router.push( + buildCrossProviderDetailHref( + { complianceId, title, version }, + Object.fromEntries(searchParams.entries()), + ), + ); + }; + + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigateToDetail(); + } + }} + > + +
+
+ {getComplianceIcon(title) && ( +
+ {`${title} +
+ )} +
+ + +

+ {formattedTitle} +

+
+ {description} +
+ + + {requirementsPassed} / {totalRequirements} + + Passing Requirements + +
+
+ +
+
+ + Score: + + + {ratingPercentage}% + +
+ +
+ +
+
+ {providerBreakdown.map((entry) => ( + + + + + + + + {PROVIDER_DISPLAY_NAMES[entry.provider]} + {entry.unscanned + ? " — no completed scan yet" + : ` — ${entry.score}% passing`} + + + ))} +
+ + {requirementsFailed} failed · {requirementsManual} manual + +
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.test.tsx new file mode 100644 index 0000000000..202a92eb28 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CrossProviderHubLink } from "./cross-provider-hub-link"; + +describe("CrossProviderHubLink", () => { + it("opens the framework page in Prowler Hub safely", () => { + // Given / When + render(); + + // Then + const link = screen.getByRole("link", { name: /view on prowler hub/i }); + expect(link).toHaveAttribute( + "href", + "https://hub.prowler.com/compliance/cis_controls_8.1", + ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.tsx new file mode 100644 index 0000000000..d531940fd1 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-hub-link.tsx @@ -0,0 +1,24 @@ +import { SquareArrowOutUpRight } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/shadcn/button/button"; + +interface CrossProviderHubLinkProps { + complianceId: string; +} + +export const CrossProviderHubLink = ({ + complianceId, +}: CrossProviderHubLinkProps) => ( + +); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx new file mode 100644 index 0000000000..b2fb3b66f4 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx @@ -0,0 +1,137 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ACTION_ERROR_STATUS, USAGE_LIMIT_MESSAGE } from "@/lib/action-errors"; + +import { getCrossProviderComplianceOverview } from "../_actions/cross-provider"; +import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks"; +import type { CrossProviderOverviewResult } from "../_types"; +import { + CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, + CROSS_PROVIDER_OVERVIEW_TYPE, +} from "../_types"; +import { CrossProviderOverview } from "./cross-provider-overview"; + +vi.mock("../_actions/cross-provider", () => ({ + getCrossProviderComplianceOverview: vi.fn(), +})); + +vi.mock("@/actions/providers", () => ({ + getAllProviders: vi.fn().mockResolvedValue({ data: [] }), +})); + +vi.mock("@/actions/manage-groups/manage-groups", () => ({ + getAllProviderGroups: vi.fn().mockResolvedValue({ data: [] }), +})); + +vi.mock("./cross-provider-filters", () => ({ + CrossProviderFilters: () =>
, +})); + +vi.mock("./cross-provider-framework-card", () => ({ + CrossProviderFrameworkCard: ({ title }: { title: string }) => ( +
{title}
+ ), +})); + +const successResult = (complianceId: string): CrossProviderOverviewResult => ({ + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, + response: { + data: { + type: CROSS_PROVIDER_OVERVIEW_TYPE, + id: complianceId, + attributes: { + compliance_id: complianceId, + framework: complianceId, + name: complianceId, + version: "1.0", + description: "", + compatible_providers: ["aws"], + requested_providers: ["aws"], + providers: ["aws"], + scan_ids: [], + scan_ids_by_provider: {}, + requirements_passed: 1, + requirements_failed: 0, + requirements_manual: 0, + total_requirements: 1, + requirements: [], + }, + }, + }, +}); + +const loadErrorResult: CrossProviderOverviewResult = { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, +}; + +const renderOverview = async () => + render(await CrossProviderOverview({ searchParams: {} })); + +describe("CrossProviderOverview", () => { + beforeEach(() => { + vi.mocked(getCrossProviderComplianceOverview).mockReset(); + }); + + it("degrades to a partial view when a single framework fails to load", async () => { + // Given: DORA fails, the other frameworks load + vi.mocked(getCrossProviderComplianceOverview).mockImplementation( + async ({ complianceId }) => + complianceId === "dora_2022_2554" + ? loadErrorResult + : successResult(complianceId), + ); + + // When + await renderOverview(); + + // Then: loaded cards render, the failed framework is called out by name + expect(screen.getAllByTestId("framework-card")).toHaveLength( + CROSS_PROVIDER_FRAMEWORKS.length - 1, + ); + expect(screen.getByText(/Could not load DORA/)).toBeInTheDocument(); + expect( + screen.queryByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE), + ).not.toBeInTheDocument(); + }); + + it("replaces the tab with the error alert when every framework fails to load", async () => { + // Given + vi.mocked(getCrossProviderComplianceOverview).mockResolvedValue( + loadErrorResult, + ); + + // When + await renderOverview(); + + // Then + expect( + screen.getByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE), + ).toBeInTheDocument(); + expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument(); + }); + + it("gates the whole tab on an action error even if other frameworks loaded", async () => { + // Given: one framework hits the usage limit (402) + vi.mocked(getCrossProviderComplianceOverview).mockImplementation( + async ({ complianceId }) => + complianceId === "dora_2022_2554" + ? { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, + result: { status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED }, + } + : successResult(complianceId), + ); + + // When + await renderOverview(); + + // Then + expect( + screen.getByText(new RegExp(USAGE_LIMIT_MESSAGE)), + ).toBeInTheDocument(); + expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx new file mode 100644 index 0000000000..17af2b38bf --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -0,0 +1,189 @@ +import { AlertTriangle, Info } from "lucide-react"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { SearchParamsProps } from "@/types"; +import type { KnownProviderType } from "@/types/providers"; + +import { getCrossProviderComplianceOverview } from "../_actions/cross-provider"; +import { computeProviderBreakdown } from "../_lib/cross-provider-adapter"; +import { + CROSS_PROVIDER_FRAMEWORKS, + type CrossProviderFrameworkEntry, + parseCrossProviderFilters, +} 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, + CrossProviderGroupOption, +} from "./cross-provider-filters"; +import { CrossProviderFilters } from "./cross-provider-filters"; +import { CrossProviderFrameworkCard } from "./cross-provider-framework-card"; + +/** Zero-state summary: the framework renders with every compatible provider + * chip dimmed when the API returned nothing usable (e.g. no scans yet). */ +const emptySummary = ( + entry: CrossProviderFrameworkEntry, +): CrossProviderFrameworkSummary => ({ + complianceId: entry.complianceId, + title: entry.title, + version: entry.version, + description: entry.description, + requirementsPassed: 0, + requirementsFailed: 0, + requirementsManual: 0, + totalRequirements: 0, + providerBreakdown: entry.compatibleProviders.map((provider) => ({ + provider, + pass: 0, + fail: 0, + manual: 0, + total: 0, + score: 0, + unscanned: true, + })), +}); + +/** + * Server island for the Cross-Provider tab: fetches the roll-up for every + * catalog framework in parallel and renders the filter row plus the cards + * grid. Rendered only in Prowler Cloud with the tab active, so OSS and the + * Per Scan tab never pay for these aggregation calls. + */ +export const CrossProviderOverview = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const filters = parseCrossProviderFilters(searchParams); + + const [responses, providersData, providerGroupsData] = await Promise.all([ + Promise.all( + CROSS_PROVIDER_FRAMEWORKS.map((entry) => + getCrossProviderComplianceOverview({ + complianceId: entry.complianceId, + filters, + }).then((result) => ({ entry, result })), + ), + ), + getAllProviders(), + getAllProviderGroups(), + ]); + + // Action errors (402 usage limit, 403) gate the whole feature, not one + // framework, so any of them replaces the tab instead of degrading it. + const actionError = responses.find( + ({ result }) => + result.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, + ); + if ( + actionError?.result.status === + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR + ) { + return ; + } + + // Load errors are per-framework and often transient: degrade to a partial + // view with a warning, and only replace the tab when nothing loaded. + const loadErrors = responses.flatMap(({ entry, result }) => + result.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR + ? [{ entry, result }] + : [], + ); + if (loadErrors.length === responses.length && loadErrors.length > 0) { + return ; + } + + const summaries: CrossProviderFrameworkSummary[] = responses + .filter( + ({ result }) => + result.status !== CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + ) + .map(({ entry, result }) => { + if (result.status !== CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS) { + return emptySummary(entry); + } + + const data = result.response.data; + if (!data?.attributes) return emptySummary(entry); + + const attrs = data.attributes; + return { + complianceId: entry.complianceId, + title: entry.title, + version: entry.version, + description: entry.description, + requirementsPassed: attrs.requirements_passed, + requirementsFailed: attrs.requirements_failed, + requirementsManual: attrs.requirements_manual, + totalRequirements: attrs.total_requirements, + providerBreakdown: computeProviderBreakdown(attrs), + }; + }); + + const compatibleTypes = Array.from( + new Set( + CROSS_PROVIDER_FRAMEWORKS.flatMap((entry) => entry.compatibleProviders), + ), + ).sort(); + + const providerAccounts: CrossProviderAccountOption[] = ( + providersData?.data || [] + ) + .filter((provider) => + compatibleTypes.some((type) => type === provider.attributes.provider), + ) + .map((provider) => ({ + id: provider.id, + label: provider.attributes.alias + ? `${provider.attributes.alias} (${provider.attributes.uid})` + : provider.attributes.uid, + type: provider.attributes.provider, + })); + + const providerGroups: CrossProviderGroupOption[] = ( + providerGroupsData?.data || [] + ).map((group) => ({ id: group.id, name: group.attributes.name })); + + return ( +
+ + + {loadErrors.length > 0 && ( + + + + Could not load{" "} + {loadErrors.map(({ entry }) => entry.title).join(", ")}. Showing the + frameworks that loaded — try again later. + + + )} + + {loadErrors.length === 0 && + summaries.every((summary) => summary.totalRequirements === 0) && ( + + + + No cross-provider compliance data yet. Universal frameworks + aggregate the latest completed scan of every compatible provider — + run a scan to populate these cards. + + + )} + +
+ {summaries.map((summary) => ( + + ))} +
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx new file mode 100644 index 0000000000..2f04c202db --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx @@ -0,0 +1,233 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CrossProviderPdfButton } from "./cross-provider-pdf-button"; + +// Radix dialogs/dropdowns rely on pointer-capture and scrollIntoView, which +// jsdom does not implement. +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); + +const { + generatePdfMock, + trackAndPollMock, + downloadPdfMock, + toastMock, + storeState, +} = vi.hoisted(() => ({ + generatePdfMock: vi.fn(), + trackAndPollMock: vi.fn(), + downloadPdfMock: vi.fn(), + toastMock: vi.fn(), + storeState: { + tasks: {} as Record< + string, + { + taskId: string; + kind: string; + status: string; + meta: Record; + startedAt: number; + } + >, + }, +})); + +vi.mock("../_actions/cross-provider", () => ({ + generateCrossProviderPdf: generatePdfMock, +})); + +vi.mock("../_lib/cross-provider-pdf", () => ({ + CROSS_PROVIDER_PDF_TASK_KIND: "cross-provider-pdf", + buildCrossProviderPdfTaskScope: vi.fn(() => "scope-1"), + downloadCrossProviderPdf: downloadPdfMock, + crossProviderPdfHandler: { onReady: vi.fn(), onError: vi.fn() }, +})); + +vi.mock("@/store/task-watcher/store", () => ({ + TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" }, + trackAndPollTask: trackAndPollMock, + useTaskWatcherStore: (selector: (state: typeof storeState) => unknown) => + selector(storeState), +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, + ToastAction: () => null, +})); + +const props = { + complianceId: "csa_ccm_4.0", + filters: { scanIds: ["scan-1"], providerTypes: "aws" }, + latestPdf: null, +}; + +describe("CrossProviderPdfButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + storeState.tasks = {}; + generatePdfMock.mockResolvedValue({ taskId: "task-1" }); + }); + + const openGenerateModal = async ( + user: ReturnType, + ) => { + await user.click(screen.getByRole("button", { name: /report/i })); + await user.click( + await screen.findByRole("menuitem", { name: /generate new report/i }), + ); + }; + + it("generates a named report without unsupported requirement options", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await openGenerateModal(user); + await user.type( + screen.getByLabelText(/report name/i), + "quarterly-audit.pdf", + ); + expect(screen.queryByLabelText(/only failed/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/include manual/i)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^generate$/i })); + + // Then + await waitFor(() => expect(generatePdfMock).toHaveBeenCalledTimes(1)); + expect(generatePdfMock).toHaveBeenCalledWith({ + complianceId: "csa_ccm_4.0", + filters: props.filters, + reportName: "quarterly-audit.pdf", + }); + expect(trackAndPollMock).toHaveBeenCalledWith({ + taskId: "task-1", + kind: "cross-provider-pdf", + meta: expect.objectContaining({ complianceId: "csa_ccm_4.0" }), + }); + }); + + it("surfaces generation errors as a destructive toast without tracking", async () => { + generatePdfMock.mockResolvedValue({ error: "No compatible scans." }); + const user = userEvent.setup(); + render(); + + await openGenerateModal(user); + await user.click(screen.getByRole("button", { name: /^generate$/i })); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ), + ); + expect(trackAndPollMock).not.toHaveBeenCalled(); + }); + + it("shows a generating state while a task for this framework is pending", () => { + storeState.tasks = { + "task-9": { + taskId: "task-9", + kind: "cross-provider-pdf", + status: "pending", + meta: { complianceId: "csa_ccm_4.0", scopeKey: "scope-1" }, + startedAt: Date.now(), + }, + }; + + render(); + + expect(screen.getByRole("button", { name: /generating/i })).toBeDisabled(); + }); + + it("offers an instant download when a matching report already exists", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /report/i })); + await user.click( + await screen.findByRole("menuitem", { name: /download latest/i }), + ); + + await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-7")); + }); + + it("keeps a completed report downloadable after the ready toast closes", async () => { + // Given + storeState.tasks = { + "task-8": { + taskId: "task-8", + kind: "cross-provider-pdf", + status: "ready", + meta: { + complianceId: "csa_ccm_4.0", + scopeKey: "scope-1", + reportLabel: "quarterly-audit.pdf", + }, + startedAt: Date.now(), + }, + }; + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: /report/i })); + await user.click( + await screen.findByRole("menuitem", { name: /download latest/i }), + ); + + // Then + await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-8")); + }); + + it("does not offer a completed report from a different filter scope", async () => { + // Given + storeState.tasks = { + "task-other-scope": { + taskId: "task-other-scope", + kind: "cross-provider-pdf", + status: "ready", + meta: { + complianceId: "csa_ccm_4.0", + scopeKey: "another-scope", + }, + startedAt: Date.now(), + }, + }; + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: /report/i })); + + // Then + expect( + screen.queryByRole("menuitem", { name: /download latest/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx new file mode 100644 index 0000000000..163ef91ed0 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { ChevronDownIcon, DownloadIcon, FileTextIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button, Label } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { FormButtons } from "@/components/shadcn/form"; +import { Input } from "@/components/shadcn/input/input"; +import { Modal } from "@/components/shadcn/modal"; +import { toast } from "@/components/shadcn/toast"; +import { + TASK_WATCHER_STATUS, + trackAndPollTask, + useTaskWatcherStore, +} from "@/store/task-watcher/store"; + +import { generateCrossProviderPdf } from "../_actions/cross-provider"; +import { + buildCrossProviderPdfTaskScope, + CROSS_PROVIDER_PDF_TASK_KIND, + downloadCrossProviderPdf, +} from "../_lib/cross-provider-pdf"; +import type { + CrossProviderApiFilters, + LatestCrossProviderPdf, +} from "../_types"; + +interface CrossProviderPdfButtonProps { + complianceId: string; + /** The filters (and exact scan ids) of the view currently on screen, so + * the generated PDF matches what the user is looking at. */ + filters: CrossProviderApiFilters; + /** Already-generated report matching these filters, if any — offered as an + * instant download instead of forcing a re-generate. */ + latestPdf: LatestCrossProviderPdf | null; +} + +export const CrossProviderPdfButton = ({ + complianceId, + filters, + latestPdf, +}: CrossProviderPdfButtonProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [reportName, setReportName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const taskScope = buildCrossProviderPdfTaskScope(complianceId, filters); + + const isGenerating = useTaskWatcherStore((state) => + Object.values(state.tasks).some( + (task) => + task.kind === CROSS_PROVIDER_PDF_TASK_KIND && + task.status === TASK_WATCHER_STATUS.PENDING && + task.meta.scopeKey === taskScope, + ), + ); + const completedTask = useTaskWatcherStore((state) => + Object.values(state.tasks).reduce<(typeof state.tasks)[string] | undefined>( + (latest, task) => { + if ( + task.kind !== CROSS_PROVIDER_PDF_TASK_KIND || + task.status !== TASK_WATCHER_STATUS.READY || + task.meta.scopeKey !== taskScope + ) { + return latest; + } + + return !latest || task.startedAt > latest.startedAt ? task : latest; + }, + undefined, + ), + ); + const availablePdf: LatestCrossProviderPdf | null = completedTask + ? { + taskId: completedTask.taskId, + filename: completedTask.meta.reportLabel, + } + : latestPdf; + + const handleGenerate = async () => { + setSubmitting(true); + try { + const result = await generateCrossProviderPdf({ + complianceId, + filters, + reportName: reportName.trim() || undefined, + }); + + if ("error" in result) { + toast({ + variant: "destructive", + title: "Could not start report generation", + description: result.error, + }); + return; + } + + setDialogOpen(false); + toast({ + title: "Report generation started", + description: + "We'll let you know when the PDF is ready — you can keep working meanwhile.", + }); + await trackAndPollTask({ + taskId: result.taskId, + kind: CROSS_PROVIDER_PDF_TASK_KIND, + meta: { + complianceId, + scopeKey: taskScope, + ...(reportName.trim() ? { reportLabel: reportName.trim() } : {}), + }, + }); + } catch { + // The action returns {error} for API failures; this guards the + // server-action RPC itself (e.g. a network drop mid-request). + toast({ + variant: "destructive", + title: "Could not start report generation", + description: "An unexpected error occurred. Please try again later.", + }); + } finally { + setSubmitting(false); + } + }; + + const formatGeneratedAt = (completedAt?: string) => { + if (!completedAt) return ""; + const date = new Date(completedAt); + return Number.isNaN(date.getTime()) + ? "" + : ` (${date.toLocaleDateString()})`; + }; + + return ( + <> + {/* Same trigger as the per-scan detail's export dropdown, so both + compliance headers expose one consistent "Report" action. */} + {isGenerating ? ( + + ) : ( + + Report + + + } + > + {availablePdf && ( + } + label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`} + description={availablePdf.filename} + onSelect={() => downloadCrossProviderPdf(availablePdf.taskId)} + /> + )} + } + label="Generate new report…" + onSelect={() => setDialogOpen(true)} + /> + + )} + + +
{ + event.preventDefault(); + handleGenerate(); + }} + className="flex flex-col gap-6" + > +
+
+ + setReportName(event.target.value)} + /> +
+
+ + setDialogOpen(false)} + submitText="Generate" + loadingText="Starting..." + isDisabled={submitting} + /> + +
+ + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx new file mode 100644 index 0000000000..98a9f11aab --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react"; +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(() => ({ + clientAccordionContentMock: vi.fn( + ({ + requirement, + scanIds, + }: { + requirement: Requirement; + scanIds: string[]; + framework: string; + checkProviders: CheckProviderTypesMap; + disableFindings?: boolean; + }) => ( +
+ {scanIds.join("|")}:{requirement.check_ids.join("|")}: + {requirement.status} +
+ ), + ), +})); + +// The real ClientAccordionContent drags the findings/server-action chain into +// jsdom; its behavior has its own tests. Here we assert the combined-view +// composition around it. +vi.mock( + "@/components/compliance/compliance-accordion/client-accordion-content", + () => ({ ClientAccordionContent: clientAccordionContentMock }), +); + +const mappedRequirement: Requirement = { + name: "A&A-01 - Audit and Assurance Policy and Procedures", + description: "Establish audit policies.", + status: "FAIL", + pass: 0, + fail: 1, + manual: 0, + check_ids: ["aws_check", "azure_check", "shared_check"], + scope_applicability: "IaaS", +}; + +const extras: CrossProviderRequirementExtras = { + requirementId: "A&A-01", + providers: { aws: "FAIL", azure: "PASS" }, + checkIdsByProvider: { + aws: ["aws_check", "shared_check"], + azure: ["azure_check", "shared_check"], + }, + scanIdsByProvider: { + aws: ["scan-aws-1", "scan-aws-2"], + azure: ["scan-azure-1"], + }, +}; + +describe("CrossProviderRequirementContent", () => { + it("renders the requirement once with all contributing scans combined", () => { + clientAccordionContentMock.mockClear(); + render( + , + ); + + // One combined block — no per-provider sections repeating the detail. + expect(clientAccordionContentMock).toHaveBeenCalledTimes(1); + expect(screen.queryByText(/account 1 of/)).not.toBeInTheDocument(); + expect(screen.getByTestId("findings-content")).toHaveTextContent( + "scan-aws-1|scan-aws-2|scan-azure-1:aws_check|azure_check|shared_check:FAIL", + ); + + // The mapped requirement passes through untouched (union checks, + // roll-up status, detail fields for getDetailsComponent). + const call = clientAccordionContentMock.mock.calls.at(-1)?.[0]; + expect(call?.requirement).toBe(mappedRequirement); + expect(call?.framework).toBe("CSA-CCM"); + expect(call?.disableFindings).toBe(false); + }); + + it("disables findings when no provider contributed any check", () => { + clientAccordionContentMock.mockClear(); + render( + , + ); + + const call = clientAccordionContentMock.mock.calls.at(-1)?.[0]; + expect(call?.disableFindings).toBe(true); + }); + + it("shows an empty state when no provider scan contributed", () => { + clientAccordionContentMock.mockClear(); + render( + , + ); + + expect( + screen.getByText(/No provider scan contributed/), + ).toBeInTheDocument(); + expect(clientAccordionContentMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx new file mode 100644 index 0000000000..e0d05f8f4d --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import type { Requirement } from "@/types/compliance"; +import { PROVIDER_TYPES } from "@/types/providers"; + +import { invertCheckIdsByProvider } from "../_lib/cross-provider-adapter"; +import type { CrossProviderRequirementExtras } from "../_types"; + +interface CrossProviderRequirementContentProps { + /** The requirement as produced by the framework mapper (roll-up level). */ + requirement: Requirement; + extras: CrossProviderRequirementExtras; + framework: string; +} + +/** + * Combined findings view for a cross-provider requirement: the requirement + * detail rendered once, one checks list labeling each check with its provider + * types, and a single findings table querying every contributing scan at once + * (each row carries its own provider column). Mounts lazily — the requirement + * accordion unmounts collapsed content, so the combined fetch only fires on + * expand. + */ +export const CrossProviderRequirementContent = ({ + requirement, + extras, + framework, +}: CrossProviderRequirementContentProps) => { + const contributingTypes = PROVIDER_TYPES.filter( + (type) => extras.providers[type], + ); + + if (contributingTypes.length === 0) { + return ( +

+ No provider scan contributed to this requirement with the current + filters. +

+ ); + } + + const scanIds = Array.from( + new Set( + contributingTypes.flatMap((type) => extras.scanIdsByProvider[type] ?? []), + ), + ); + + return ( + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx new file mode 100644 index 0000000000..d1f73d264a --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx @@ -0,0 +1,53 @@ +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", () => ({ + ProviderTypeIcon: () =>