From 69af0281d66c81dace970db94f6b8f4e34185276 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Wed, 22 Jul 2026 13:49:21 +0200 Subject: [PATCH] fix(ui): harden multi-scan compliance flows Scope cross-account catalogs to active filters and resolve representative scans per provider type. Reuse shared actions, PDF handlers, accordion builders, detail shells, and Design System variants while adding request timeouts, accessible status breakdowns, and direct regression coverage. --- .../compliance/_actions/cross-account.test.ts | 192 +++++++++++ .../compliance/_actions/cross-account.ts | 276 +++------------- .../compliance/_actions/cross-provider.ts | 302 +++--------------- .../aggregated-compliance-detail.tsx | 83 +++++ .../_components/aggregated-framework-card.tsx | 85 +++++ .../aggregated-requirement-content.tsx | 32 ++ .../_components/compliance-section-header.tsx | 17 - .../_components/cross-account-detail.tsx | 148 +++------ .../cross-account-framework-card.tsx | 75 ++--- .../cross-account-overview-section.test.tsx | 90 ++++++ .../cross-account-overview-section.tsx | 163 +++++----- .../cross-account-requirement-content.tsx | 16 +- .../_components/cross-provider-detail.tsx | 144 +++------ .../cross-provider-framework-card.tsx | 165 ++++------ .../_components/cross-provider-overview.tsx | 41 ++- .../cross-provider-pdf-button.test.tsx | 25 +- .../cross-provider-requirement-content.tsx | 16 +- .../_components/provider-coverage-card.tsx | 81 ++--- .../requirement-status-summary.test.tsx | 30 ++ .../requirement-status-summary.tsx | 92 +++--- .../cross-account-mapper-parity.test.tsx | 13 +- .../_lib/aggregated-compliance-accordion.tsx | 161 ++++++++++ .../_lib/aggregated-compliance-actions.ts | 296 +++++++++++++++++ .../_lib/aggregated-compliance-detail.ts | 28 ++ .../_lib/aggregated-compliance-pdf.tsx | 90 ++++++ .../_lib/cross-account-accordion.tsx | 231 ++------------ .../compliance/_lib/cross-account-pdf.tsx | 88 ++--- .../_lib/cross-provider-accordion.tsx | 212 ++---------- .../compliance/_lib/cross-provider-pdf.tsx | 97 ++---- ...compliance-accordion-requeriment-title.tsx | 16 +- ui/components/shadcn/card/card.test.tsx | 14 +- ui/components/shadcn/card/card.tsx | 15 +- ui/components/shadcn/progress.test.tsx | 14 + ui/components/shadcn/progress.tsx | 12 +- .../shadcn/scroll-area/scroll-area.test.tsx | 14 + .../shadcn/scroll-area/scroll-area.tsx | 15 +- 36 files changed, 1796 insertions(+), 1593 deletions(-) create mode 100644 ui/app/(prowler)/compliance/_actions/cross-account.test.ts create mode 100644 ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx create mode 100644 ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx create mode 100644 ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx delete mode 100644 ui/app/(prowler)/compliance/_components/compliance-section-header.tsx create mode 100644 ui/app/(prowler)/compliance/_components/requirement-status-summary.test.tsx create mode 100644 ui/app/(prowler)/compliance/_lib/aggregated-compliance-accordion.tsx create mode 100644 ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts create mode 100644 ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts create mode 100644 ui/app/(prowler)/compliance/_lib/aggregated-compliance-pdf.tsx create mode 100644 ui/components/shadcn/progress.test.tsx create mode 100644 ui/components/shadcn/scroll-area/scroll-area.test.tsx diff --git a/ui/app/(prowler)/compliance/_actions/cross-account.test.ts b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts new file mode 100644 index 0000000000..f79cc21ea9 --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + captureExceptionMock, + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + captureExceptionMock: vi.fn(), + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, +})); + +import { + generateCrossAccountPdf, + getCrossAccountComplianceOverview, + getCrossAccountPdfBinary, + getLatestCrossAccountPdf, +} from "./cross-account"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); + +const lastFetchCall = () => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + return { + init: call[1] as RequestInit, + url: new URL(String(call[0])), + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue(jsonResponse({ data: null })); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer test-token" }); + handleApiResponseMock.mockResolvedValue({ data: null }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cross-account compliance actions", () => { + it("loads the overview with its identity and account filters", async () => { + const payload = { data: { id: "cis_2.0_aws" } }; + handleApiResponseMock.mockResolvedValue(payload); + + const result = await getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { + scanIds: ["scan-1", "scan-2"], + providerIds: "provider-1,provider-2", + providerGroups: "group-1", + }, + }); + + expect(result).toEqual({ status: "success", response: payload }); + const { init, url } = lastFetchCall(); + expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews"); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[provider_type]")).toBe("aws"); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1,provider-2", + ); + expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("aborts a stalled request and reports the network failure", async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + requestSignal = init?.signal ?? undefined; + requestSignal?.addEventListener("abort", () => { + reject(requestSignal?.reason ?? new Error("aborted")); + }); + }), + ); + + const request = getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + }); + await vi.advanceTimersByTimeAsync(30_000); + + await expect(request).resolves.toEqual({ + status: "load-error", + message: + "Could not load cross-provider compliance data. Try again later.", + }); + expect(requestSignal?.aborted).toBe(true); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + it("starts PDF generation through the shared task protocol", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ); + + const result = await generateCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { scanIds: ["scan-1"] }, + reportName: "aws-report.pdf", + }); + + expect(result).toEqual({ taskId: "task-1" }); + const { init, url } = lastFetchCall(); + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews/pdf"); + expect(url.searchParams.get("report_name")).toBe("aws-report.pdf"); + }); + + it("retrieves a completed PDF with its response filename", async () => { + fetchMock.mockResolvedValue( + new Response(Buffer.from("pdf-bytes"), { + headers: { + "Content-Disposition": 'attachment; filename="aws-report.pdf"', + "Content-Type": "application/pdf", + }, + }), + ); + + const result = await getCrossAccountPdfBinary("task-1"); + + expect(result).toEqual({ + success: true, + data: Buffer.from("pdf-bytes").toString("base64"), + filename: "aws-report.pdf", + }); + expect(lastFetchCall().init.signal).toBeInstanceOf(AbortSignal); + }); + + it("returns the latest matching PDF descriptor", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + data: { + id: "task-9", + attributes: { + completed_at: "2026-07-01T10:00:00Z", + result: { filename: "latest.pdf" }, + }, + }, + }), + ); + + const result = await getLatestCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { providerIds: "provider-1" }, + }); + + expect(result).toEqual({ + taskId: "task-9", + filename: "latest.pdf", + completedAt: "2026-07-01T10:00:00Z", + }); + const { init, url } = lastFetchCall(); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/latest", + ); + expect(url.searchParams.get("filter[provider_id__in]")).toBe("provider-1"); + }); +}); diff --git a/ui/app/(prowler)/compliance/_actions/cross-account.ts b/ui/app/(prowler)/compliance/_actions/cross-account.ts index b9f7de4e40..06568bba70 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-account.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-account.ts @@ -1,87 +1,23 @@ "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 { apiBaseUrl } from "@/lib"; +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; import type { CrossAccountApiFilters, CrossAccountOverviewResponse, CrossAccountOverviewResult, LatestCrossProviderPdf, } from "../_types"; -import { - CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, -} from "../_types"; const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews"; -/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */ -interface PdfEndpointErrorBody { - errors?: Array<{ detail?: string }>; - error?: string; - message?: string; -} - -/** Cross-account sibling of the cross-provider action module's helper: a - * user-safe message from a failed PDF endpoint response, with 5xx reported - * to Sentry. `operation` must be a STATIC route template. */ -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); - - if (response.status >= 500) { - Sentry.captureException( - new Error( - `Cross-account 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 required identity params + shared filters to a request URL. */ const applyCrossAccountParams = ( url: URL, complianceId: string, @@ -91,30 +27,30 @@ const applyCrossAccountParams = ( url.searchParams.set("filter[compliance_id]", complianceId); url.searchParams.set("filter[provider_type]", providerType); - if (filters?.scanIds && filters.scanIds.length > 0) { + if (filters?.scanIds?.length) { url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); } - const paramMap = { + + const params = { "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); - } + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) url.searchParams.set(key, value); } }; -/** - * Aggregate a regular (per-provider) compliance framework across the latest - * completed scan of every visible account of one provider type (Prowler - * Cloud only — the OSS API has no such endpoint). - * - * When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED - * scan per account. Non-2xx responses are returned as structured action - * errors so callers can reuse the app-wide 402/403 handlers, mirroring - * `getCrossProviderComplianceOverview`. - */ +const buildCrossAccountUrl = ( + suffix: string, + complianceId: string, + providerType: string, + filters?: CrossAccountApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}${suffix}`); + applyCrossAccountParams(url, complianceId, providerType, filters); + return url; +}; + export const getCrossAccountComplianceOverview = async ({ complianceId, providerType, @@ -124,41 +60,13 @@ export const getCrossAccountComplianceOverview = async ({ providerType: string; filters?: CrossAccountApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}`); - applyCrossAccountParams(url, complianceId, providerType, 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 CrossAccountOverviewResponse, - }; - } catch (error) { - console.error("Error fetching cross-account compliance overview:", error); - return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, - message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - }; - } + const url = buildCrossAccountUrl("", complianceId, providerType, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_ACCOUNT_API_PATH}`, + ); }; -/** - * Trigger ad-hoc generation of the combined cross-account compliance PDF. - * Mirrors `generateCrossProviderPdf`: returns the async task id so the - * caller can track it and download via {@link getCrossAccountPdfBinary}. - * Pass the exact `scanIds` currently on screen so the report matches the - * displayed data instead of racing a scan completing in between. - */ export const generateCrossAccountPdf = async ({ complianceId, providerType, @@ -168,45 +76,16 @@ export const generateCrossAccountPdf = async ({ complianceId: string; providerType: string; filters?: CrossAccountApiFilters; - /** 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_ACCOUNT_API_PATH}/pdf`); - applyCrossAccountParams(url, complianceId, providerType, filters); + const url = buildCrossAccountUrl("/pdf", complianceId, providerType, 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_ACCOUNT_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) }; - } + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_ACCOUNT_API_PATH}/pdf`, + ); }; -/** - * Fetch the finished cross-account PDF for a task started by - * {@link generateCrossAccountPdf}. Same 202-pending / 2xx-binary / error-JSON - * protocol (and shared `ScanBinaryResult` shape) as the cross-provider - * download action. - */ export const getCrossAccountPdfBinary = async ( taskId: string, ): Promise => { @@ -215,53 +94,16 @@ export const getCrossAccountPdfBinary = async ( return { error: "Invalid task identifier." }; } - const headers = await getAuthHeaders({ contentType: false }); const url = new URL( `${apiBaseUrl}${CROSS_ACCOUNT_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_ACCOUNT_API_PATH}/pdf/{taskId}`, - ), - ); - } - - const contentDisposition = - response.headers.get("content-disposition") || ""; - const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); - const filename = filenameMatch?.[1] || "cross-account-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) }; - } + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-account-compliance.pdf", + }); }; -/** - * Check whether a cross-account PDF already exists for the given filters — - * same degrade-to-`null` contract as `getLatestCrossProviderPdf` (404 means - * "not generated yet"; the report goes stale the moment any account - * completes a new scan). - */ export const getLatestCrossAccountPdf = async ({ complianceId, providerType, @@ -271,36 +113,14 @@ export const getLatestCrossAccountPdf = async ({ providerType: string; filters?: CrossAccountApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/latest`); - applyCrossAccountParams(url, complianceId, providerType, 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_ACCOUNT_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) { - console.error("Error checking for an existing cross-account PDF:", error); - return null; - } + const url = buildCrossAccountUrl( + "/pdf/latest", + complianceId, + providerType, + filters, + ); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`, + ); }; diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.ts index eacdc29fde..1cd9f77fac 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-provider.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.ts @@ -1,119 +1,54 @@ "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 { apiBaseUrl } from "@/lib"; +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; 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; -} +const applyCrossProviderParams = ( + url: URL, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + url.searchParams.set("filter[compliance_id]", complianceId); -/** - * 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) { + if (filters?.scanIds?.length) { 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, + const params = { + "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); - } + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) 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. - */ +const buildCrossProviderUrl = ( + suffix: string, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}${suffix}`); + applyCrossProviderParams(url, complianceId, filters); + return url; +}; + export const getCrossProviderComplianceOverview = async ({ complianceId, filters, @@ -121,45 +56,13 @@ export const getCrossProviderComplianceOverview = async ({ 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, - }; - } + const url = buildCrossProviderUrl("", complianceId, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_PROVIDER_API_PATH}`, + ); }; -/** - * 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, @@ -167,109 +70,34 @@ export const generateCrossProviderPdf = async ({ }: { 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); + const url = buildCrossProviderUrl("/pdf", complianceId, 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) }; - } + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_PROVIDER_API_PATH}/pdf`, + ); }; -/** - * 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) }; - } + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-provider-compliance.pdf", + }); }; -/** - * 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, @@ -277,39 +105,9 @@ export const getLatestCrossProviderPdf = async ({ 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; - } + const url = buildCrossProviderUrl("/pdf/latest", complianceId, filters); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`, + ); }; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx new file mode 100644 index 0000000000..b245e79762 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx @@ -0,0 +1,83 @@ +import Image from "next/image"; +import type { ComponentProps, ReactNode } from "react"; + +import { + ClientAccordionWrapper, + RequirementsStatusCard, + TopFailedSectionsCard, +} from "@/components/compliance"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import { Card } from "@/components/shadcn/card/card"; +import type { RequirementsTotals } from "@/types/compliance"; + +interface AggregatedComplianceDetailProps { + compliancetitle: string; + logoPath?: ComponentProps["src"]; + title: ReactNode; + description: ReactNode; + reportAction: ReactNode; + filters: ReactNode; + totals: RequirementsTotals; + coverage: ReactNode; + topFailed: ComponentProps; + accordionItems: AccordionItemProps[]; + initialExpandedKeys: string[]; +} + +export const AggregatedComplianceDetail = ({ + compliancetitle, + logoPath, + title, + description, + reportAction, + filters, + totals, + coverage, + topFailed, + accordionItems, + initialExpandedKeys, +}: AggregatedComplianceDetailProps) => ( +
+ +
+
+
+ {logoPath && ( +
+ {`${compliancetitle} +
+ )} +
+ {title} + {description} +
+
+
{reportAction}
+
+ {filters} +
+
+ +
+ + {coverage} + +
+ + +
+); diff --git a/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx new file mode 100644 index 0000000000..fbc8fc9539 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx @@ -0,0 +1,85 @@ +import Image from "next/image"; +import type { KeyboardEventHandler, ReactNode } from "react"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + +interface AggregatedFrameworkCardProps { + frameworkTitle: string; + formattedTitle: string; + ariaLabel: string; + onActivate: () => void; + subtitle: ReactNode; + tooltip?: string; + children: ReactNode; +} + +export const AggregatedFrameworkCard = ({ + frameworkTitle, + formattedTitle, + ariaLabel, + onActivate, + subtitle, + tooltip, + children, +}: AggregatedFrameworkCardProps) => { + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(); + } + }; + const logo = getComplianceIcon(frameworkTitle); + const title = ( +

{formattedTitle}

+ ); + + return ( + + +
+
+ {logo && ( +
+ {`${frameworkTitle} +
+ )} +
+ {tooltip ? ( + + {title} + {tooltip} + + ) : ( + title + )} + {subtitle} +
+
+ {children} +
+
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx new file mode 100644 index 0000000000..e3975b36d7 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import type { CheckProviderTypesMap, Requirement } from "@/types/compliance"; + +interface AggregatedRequirementContentProps { + requirement: Requirement; + framework: string; + scanIds: string[]; + emptyMessage: string; + checkProviders?: CheckProviderTypesMap; +} + +export const AggregatedRequirementContent = ({ + requirement, + framework, + scanIds, + emptyMessage, + checkProviders, +}: AggregatedRequirementContentProps) => { + if (scanIds.length === 0) return

{emptyMessage}

; + + return ( + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx b/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx deleted file mode 100644 index b69655056a..0000000000 --- a/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface ComplianceSectionHeaderProps { - title: string; - description: string; -} - -/** Shared heading for the Multiple Scans tab's framework sections - * ("Across provider types" / "Across providers"), so both explain their - * aggregation axis with one consistent look. */ -export const ComplianceSectionHeader = ({ - title, - description, -}: ComplianceSectionHeaderProps) => ( -
-

{title}

-

{description}

-
-); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx index bed8951236..1696fab333 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx @@ -1,19 +1,11 @@ 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 { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; 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 { type KnownProviderType, PROVIDER_DISPLAY_NAMES, @@ -23,6 +15,10 @@ import { getCrossAccountComplianceOverview, getLatestCrossAccountPdf, } from "../_actions/cross-account"; +import { + getAggregatedInitialExpandedKeys, + getAggregatedRequirementsTotals, +} from "../_lib/aggregated-compliance-detail"; import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion"; import { buildAccountExtrasMap, @@ -32,6 +28,7 @@ import { import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; +import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, @@ -132,14 +129,7 @@ export const CrossAccountDetail = async ({ }), ); - 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 totals = getAggregatedRequirementsTotals(data); const accordionItems = toCrossAccountAccordionItems( data, extras, @@ -148,18 +138,11 @@ export const CrossAccountDetail = async ({ ); 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 initialExpandedKeys = getAggregatedInitialExpandedKeys( + data, + accordionItems, + targetSection, + ); const logoPath = getComplianceIcon(compliancetitle); @@ -180,79 +163,52 @@ export const CrossAccountDetail = async ({ ).map((group) => ({ id: group.id, name: group.attributes.name })); return ( -
- {/* Header card — same structure as the cross-provider detail: identity - row (logo + context), filters below. */} - -
-
-
- {logoPath && ( -
- {`${compliancetitle} -
- )} -
- - {attrs.name || compliancetitle.split("-").join(" ")} - -

- - {PROVIDER_DISPLAY_NAMES[providerType]} ·{" "} - {attrs.accounts.length}{" "} - {attrs.accounts.length === 1 ? "account" : "accounts"}{" "} - aggregated · {attrs.scan_ids.length}{" "} - {attrs.scan_ids.length === 1 ? "scan" : "scans"} -

-
-
-
- -
-
- - {/* No providerTypes: the type select is meaningless here — the - provider type is fixed by the framework being viewed. */} - -
-
- -
- + {attrs.name || compliancetitle.split("-").join(" ")} + + } + description={ +

+ + {PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "} + {attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "} + {attrs.scan_ids.length}{" "} + {attrs.scan_ids.length === 1 ? "scan" : "scans"} +

+ } + reportAction={ + + } + filters={ + + } + totals={totals} + coverage={ - -
- - -
+ } + topFailed={{ + sections: topFailedResult.items, + dataType: topFailedResult.type, + prepopulated: topFailedResult.prepopulated, + }} + accordionItems={accordionItems} + initialExpandedKeys={initialExpandedKeys} + /> ); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx index 60966a82ce..7760f14e88 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx @@ -1,16 +1,15 @@ "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 { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks"; import type { CrossAccountFrameworkEntry } from "../_types"; +import { AggregatedFrameworkCard } from "./aggregated-framework-card"; + /** * Card for a regular per-provider framework in the Cross-Provider tab's * "across accounts" section. Deliberately lightweight — no roll-up numbers: @@ -40,56 +39,26 @@ export const CrossAccountFrameworkCard = ({ }; return ( - { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - navigateToDetail(); - } - }} + + View across providers + + } > - -
-
- {getComplianceIcon(title) && ( -
- {`${title} -
- )} -
-

- {formattedTitle} -

- - View across providers - -
-
- -
- - - {PROVIDER_DISPLAY_NAMES[providerType]} - - - {accountCount} providers - -
-
-
-
+
+ + + {PROVIDER_DISPLAY_NAMES[providerType]} + + + {accountCount} providers + +
+ ); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx index 69b1b79b46..b765db7d41 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -164,4 +164,94 @@ describe("CrossAccountOverviewSection", () => { expect(container).toBeEmptyDOMElement(); expect(getCompliancesOverview).not.toHaveBeenCalled(); }); + + it("scopes provider counts to the active account and group filters", async () => { + // Given: three AWS providers exist, but only two match the active filters. + vi.mocked(getAllProviders).mockImplementation(async ({ filters } = {}) => { + const isFiltered = + filters?.["filter[id__in]"] === "aws-1,aws-2" && + filters?.["filter[provider_groups__in]"] === "group-1"; + + return providersResponse( + isFiltered + ? [ + { id: "aws-1", type: "aws" }, + { id: "aws-2", type: "aws" }, + ] + : [ + { id: "aws-1", type: "aws" }, + { id: "aws-2", type: "aws" }, + { id: "aws-3", type: "aws" }, + ], + ); + }); + vi.mocked(getScans).mockResolvedValue( + scansFor([{ id: "scan-1", providerId: "aws-1" }]), + ); + vi.mocked(getCompliancesOverview).mockResolvedValue({ + data: [ + { + id: "cis_2.0_aws", + attributes: { framework: "CIS", version: "2.0" }, + }, + ], + }); + + // When + await renderSection({ + "filter[provider_id__in]": "aws-1,aws-2", + "filter[provider_groups__in]": "group-1", + }); + + // Then: the overview count matches the same provider set as the detail. + expect(screen.getByText("1 framework · 2 providers")).toBeInTheDocument(); + expect(screen.queryByText(/3 providers/)).not.toBeInTheDocument(); + }); + + it("loads one representative completed scan for every eligible provider type", async () => { + // Given: two eligible types whose representative scans must be resolved + // independently, regardless of how many other scans the tenant has. + vi.mocked(getAllProviders).mockResolvedValue( + providersResponse([ + { id: "aws-1", type: "aws" }, + { id: "aws-2", type: "aws" }, + { id: "gcp-1", type: "gcp" }, + { id: "gcp-2", type: "gcp" }, + ]), + ); + vi.mocked(getScans).mockImplementation(async ({ filters }) => { + const providerType = ( + filters as Record | undefined + )?.["filter[provider_type]"]; + if (providerType === "aws") { + return scansFor([{ id: "scan-aws", providerId: "aws-1" }]); + } + if (providerType === "gcp") { + return scansFor([{ id: "scan-gcp", providerId: "gcp-1" }]); + } + return scansFor([]); + }); + vi.mocked(getCompliancesOverview).mockResolvedValue({ + data: [ + { + id: "framework-1", + attributes: { framework: "Framework", version: "1.0" }, + }, + ], + }); + + // When + await renderSection(); + + // Then + expect(screen.getByText("AWS")).toBeInTheDocument(); + expect(screen.getByText("Google Cloud")).toBeInTheDocument(); + expect(getScans).toHaveBeenCalledTimes(2); + expect(getCompliancesOverview).toHaveBeenCalledWith({ + scanId: "scan-aws", + }); + expect(getCompliancesOverview).toHaveBeenCalledWith({ + scanId: "scan-gcp", + }); + }); }); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx index 12f3e7fcbe..a0e2740ffb 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx @@ -4,7 +4,14 @@ import { getScans } from "@/actions/scans"; import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { Accordion } from "@/components/shadcn/accordion/Accordion"; -import type { ScanProps, SearchParamsProps } from "@/types"; +import { + Section, + SectionContent, + SectionDescription, + SectionHeader, + SectionTitle, +} from "@/components/shadcn/section/section"; +import type { SearchParamsProps } from "@/types"; import type { ComplianceOverviewData } from "@/types/compliance"; import { isKnownProviderType, @@ -15,7 +22,6 @@ import { import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks"; import type { CrossAccountFrameworkEntry } from "../_types"; -import { ComplianceSectionHeader } from "./compliance-section-header"; import { CrossAccountFrameworkCard } from "./cross-account-framework-card"; /** Only provider types with at least this many accounts get cross-account @@ -27,9 +33,9 @@ const MIN_ACCOUNTS = 2; * for every provider type with 2+ accounts, lists the regular (per-provider) * frameworks that can be viewed aggregated across that type's accounts. * - * The framework list per type comes from the latest completed scan of any - * account of that type (frameworks are a property of the provider type, not - * of the account). Universal frameworks are excluded — they already have + * The framework list per type comes from a completed scan of any account of + * that type (frameworks are a property of the provider type, not of the + * account). Universal frameworks are excluded — they already have * their own cross-provider cards above. Renders nothing when no provider * type qualifies, keeping the tab unchanged for single-account tenants. * Best-effort by design: a type whose scan or framework list fails to load @@ -40,88 +46,95 @@ export const CrossAccountOverviewSection = async ({ }: { searchParams: SearchParamsProps; }) => { + const providerFilters = { + "filter[provider_type__in]": + searchParams["filter[provider_type__in]"]?.toString(), + "filter[id__in]": searchParams["filter[provider_id__in]"]?.toString(), + "filter[provider_groups__in]": + searchParams["filter[provider_groups__in]"]?.toString(), + }; const providerTypeFilter = - searchParams["filter[provider_type__in]"] - ?.toString() - .split(",") - .filter(Boolean) ?? []; - - const [providersData, scansData] = await Promise.all([ - getAllProviders(), - getScans({ - filters: { "filter[state]": "completed" }, - pageSize: 50, - fields: { scans: "name,completed_at,provider" }, - include: "provider", - }), - ]); + providerFilters["filter[provider_type__in]"]?.split(",").filter(Boolean) ?? + []; + const providersData = await getAllProviders({ filters: providerFilters }); const accountCounts = new Map(); + const providerIdsByType = new Map(); for (const provider of providersData?.data || []) { const type = provider.attributes.provider; if (!isKnownProviderType(type)) continue; accountCounts.set(type, (accountCounts.get(type) ?? 0) + 1); + providerIdsByType.set(type, [ + ...(providerIdsByType.get(type) ?? []), + provider.id, + ]); } const eligibleTypes = Array.from(accountCounts.entries()) - .filter(([type, count]) => { - if (count < MIN_ACCOUNTS) return false; - return ( - providerTypeFilter.length === 0 || providerTypeFilter.includes(type) - ); - }) + .filter( + ([type, count]) => + count >= MIN_ACCOUNTS && + (providerTypeFilter.length === 0 || providerTypeFilter.includes(type)), + ) .map(([type]) => type) .sort(); if (eligibleTypes.length === 0) return null; - // Latest completed scan per eligible type (the API returns scans newest - // first). One representative scan per type is enough to enumerate the - // type's frameworks. - const latestScanByType = new Map(); - for (const scan of (scansData?.data || []) as ScanProps[]) { - const providerId = scan.relationships?.provider?.data?.id; - if (!providerId) continue; - const providerData = scansData.included?.find( - (item: { type: string; id: string }) => - item.type === "providers" && item.id === providerId, - ); - const type = providerData?.attributes?.provider; - if (!isKnownProviderType(type)) continue; - if (!eligibleTypes.includes(type) || latestScanByType.has(type)) continue; - latestScanByType.set(type, scan.id); - } + // Resolve one representative scan independently for every eligible type. + // A single global scans page can omit less-recent provider types on tenants + // with a large scan history. + const scansByType = await Promise.all( + eligibleTypes.map(async (type) => { + const scansData = await getScans({ + filters: { + "filter[state]": "completed", + "filter[provider_type]": type, + "filter[provider__in]": (providerIdsByType.get(type) ?? []).join(","), + }, + pageSize: 1, + fields: { scans: "name" }, + }); + const scanId = scansData?.data?.[0]?.id; + return scanId ? ([type, scanId] as const) : null; + }), + ); + const representativeScanByType = new Map( + scansByType.filter((entry) => entry !== null), + ); const universalIds = new Set( CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId), ); const entriesByType = await Promise.all( - Array.from(latestScanByType.entries()).map(async ([type, scanId]) => { - const compliancesData = await getCompliancesOverview({ scanId }); - const frameworks: ComplianceOverviewData[] = Array.isArray( - compliancesData?.data, - ) - ? compliancesData.data - : []; + Array.from(representativeScanByType.entries()).map( + async ([type, scanId]) => { + const compliancesData = await getCompliancesOverview({ scanId }); + const frameworks: ComplianceOverviewData[] = Array.isArray( + compliancesData?.data, + ) + ? compliancesData.data + : []; - return frameworks - .filter( - (compliance) => - compliance.attributes.framework !== "ProwlerThreatScore" && - !universalIds.has(compliance.id), - ) - .map( - (compliance): CrossAccountFrameworkEntry => ({ - complianceId: compliance.id, - title: compliance.attributes.framework, - version: compliance.attributes.version, - providerType: type, - accountCount: accountCounts.get(type) ?? 0, - }), - ) - .sort((a, b) => a.title.localeCompare(b.title)); - }), + return frameworks + .filter( + (compliance) => + compliance.attributes.framework !== "ProwlerThreatScore" && + !universalIds.has(compliance.id), + ) + .map( + (compliance): CrossAccountFrameworkEntry => ({ + complianceId: compliance.id, + title: compliance.attributes.framework, + version: compliance.attributes.version, + providerType: type, + accountCount: accountCounts.get(type) ?? 0, + }), + ) + .sort((a, b) => a.title.localeCompare(b.title)); + }, + ), ); const groups = entriesByType @@ -160,12 +173,18 @@ export const CrossAccountOverviewSection = async ({ }); return ( -
- - -
+
+ + Across providers + + Single-provider frameworks aggregated across every provider of the + same type, using each provider's latest completed scan. Expand a + provider type to browse its frameworks. + + + + + +
); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/cross-account-requirement-content.tsx index b6cdd2a4e1..d7b7c620c2 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-requirement-content.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-requirement-content.tsx @@ -1,6 +1,5 @@ "use client"; -import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import type { Requirement } from "@/types/compliance"; import type { @@ -8,6 +7,8 @@ import type { CrossAccountRequirementExtras, } from "../_types"; +import { AggregatedRequirementContent } from "./aggregated-requirement-content"; + interface CrossAccountRequirementContentProps { /** The requirement as produced by the framework mapper (roll-up level). */ requirement: Requirement; @@ -34,15 +35,6 @@ export const CrossAccountRequirementContent = ({ (account) => extras.accounts[account.id], ); - if (contributingAccounts.length === 0) { - return ( -

- No account scan contributed to this requirement with the current - filters. -

- ); - } - const scanIds = Array.from( new Set( contributingAccounts.flatMap( @@ -52,11 +44,11 @@ export const CrossAccountRequirementContent = ({ ); return ( - ); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx index 4901294ad4..73897b8a52 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -1,23 +1,19 @@ 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 { + getAggregatedInitialExpandedKeys, + getAggregatedRequirementsTotals, +} from "../_lib/aggregated-compliance-detail"; import { toCrossProviderAccordionItems } from "../_lib/cross-provider-accordion"; import { buildRequirementExtrasMap, @@ -30,6 +26,7 @@ import { } from "../_lib/cross-provider-frameworks"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; +import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, @@ -114,14 +111,7 @@ export const CrossProviderDetail = async ({ 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 totals = getAggregatedRequirementsTotals(data); const accordionItems = toCrossProviderAccordionItems( data, extras, @@ -129,18 +119,11 @@ export const CrossProviderDetail = async ({ ); 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 initialExpandedKeys = getAggregatedInitialExpandedKeys( + data, + accordionItems, + targetSection, + ); const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find( (entry) => entry.complianceId === complianceId, @@ -169,74 +152,47 @@ export const CrossProviderDetail = async ({ ).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 -

-
-
-
- -
-
- - + + + {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 +

+ } + reportAction={ + - - -
- - -
+ } + totals={totals} + coverage={} + topFailed={{ + sections: topFailedResult.items, + dataType: topFailedResult.type, + prepopulated: topFailedResult.prepopulated, + }} + accordionItems={accordionItems} + initialExpandedKeys={initialExpandedKeys} + /> ); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx index 65b87806e1..ebcfb3bf11 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-framework-card.tsx @@ -1,27 +1,23 @@ "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 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"; +import { AggregatedFrameworkCard } from "./aggregated-framework-card"; + export const CrossProviderFrameworkCard = ({ complianceId, title, @@ -60,103 +56,66 @@ export const CrossProviderFrameworkCard = ({ }; return ( - { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - navigateToDetail(); - } - }} + + + {requirementsPassed} / {totalRequirements} + + Passing Requirements + + } > - -
-
- {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 - -
+
+
+ + 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-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx index 13553ff838..f0bb2f78f2 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -3,6 +3,13 @@ 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 { + Section, + SectionContent, + SectionDescription, + SectionHeader, + SectionTitle, +} from "@/components/shadcn/section/section"; import { SearchParamsProps } from "@/types"; import type { KnownProviderType } from "@/types/providers"; @@ -16,7 +23,6 @@ import { import type { CrossProviderFrameworkSummary } from "../_types"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; -import { ComplianceSectionHeader } from "./compliance-section-header"; import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, @@ -181,20 +187,25 @@ export const CrossProviderOverview = async ({ )} -
- -
- {summaries.map((summary) => ( - - ))} -
-
+
+ + Across provider types + + Universal frameworks aggregated across every compatible provider + type, using the latest completed scan of each provider. + + + +
+ {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 index bcaff1f031..12b74a41bf 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx @@ -30,6 +30,7 @@ const { generateAccountPdfMock, trackAndPollMock, downloadPdfMock, + downloadAccountPdfMock, toastMock, storeState, } = vi.hoisted(() => ({ @@ -37,6 +38,7 @@ const { generateAccountPdfMock: vi.fn(), trackAndPollMock: vi.fn(), downloadPdfMock: vi.fn(), + downloadAccountPdfMock: vi.fn(), toastMock: vi.fn(), storeState: { tasks: {} as Record< @@ -70,7 +72,7 @@ vi.mock("../_lib/cross-provider-pdf", () => ({ vi.mock("../_lib/cross-account-pdf", () => ({ CROSS_ACCOUNT_PDF_TASK_KIND: "cross-account-pdf", buildCrossAccountPdfTaskScope: vi.fn(() => "account-scope-1"), - downloadCrossAccountPdf: vi.fn(), + downloadCrossAccountPdf: downloadAccountPdfMock, crossAccountPdfHandler: { onReady: vi.fn(), onError: vi.fn() }, })); @@ -247,6 +249,27 @@ describe("CrossProviderPdfButton", () => { }); }); + it("downloads the latest report through the cross-account action", 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(downloadAccountPdfMock).toHaveBeenCalledWith("task-acc-7"), + ); + expect(downloadPdfMock).not.toHaveBeenCalled(); + }); + it("does not offer a completed report from a different filter scope", async () => { // Given storeState.tasks = { diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx index e0d05f8f4d..66b0b6134e 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.tsx @@ -1,12 +1,13 @@ "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"; +import { AggregatedRequirementContent } from "./aggregated-requirement-content"; + interface CrossProviderRequirementContentProps { /** The requirement as produced by the framework mapper (roll-up level). */ requirement: Requirement; @@ -31,15 +32,6 @@ export const CrossProviderRequirementContent = ({ (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] ?? []), @@ -47,12 +39,12 @@ export const CrossProviderRequirementContent = ({ ); return ( - ); }; diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.tsx index 6b27bfb2db..e9b06a94b6 100644 --- a/ui/app/(prowler)/compliance/_components/provider-coverage-card.tsx +++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.tsx @@ -3,10 +3,7 @@ import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { Progress } from "@/components/shadcn/progress"; -import { - getScoreColor, - getScoreIndicatorClass, -} from "@/lib/compliance/score-utils"; +import { getScoreColor } from "@/lib/compliance/score-utils"; import type { KnownProviderType } from "@/types/providers"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; @@ -57,43 +54,47 @@ export const ProviderCoverageCard = ({ })); return ( - - - {title} - - {/* Capped + scrollable so a long list never stretches the sibling - chart cards in the same grid row. */} - - {resolvedRows.length === 0 && ( -

{emptyMessage}

- )} - {resolvedRows.map((entry) => ( -
-
- - - {entry.label} - - - {entry.score}% - -
-
- - - {entry.pass}/{entry.pass + entry.fail} · {entry.manual} manual - -
+ +
+ + {title} + + + {/* Capped + scrollable so a long list never stretches the sibling + chart cards in the same grid row. */} +
+ {resolvedRows.length === 0 && ( +

+ {emptyMessage} +

+ )} + {resolvedRows.map((entry) => ( +
+
+ + + {entry.label} + + + {entry.score}% + +
+
+ + + {entry.pass}/{entry.pass + entry.fail} · {entry.manual}{" "} + manual + +
+
+ ))}
- ))} -
+ +
); }; diff --git a/ui/app/(prowler)/compliance/_components/requirement-status-summary.test.tsx b/ui/app/(prowler)/compliance/_components/requirement-status-summary.test.tsx new file mode 100644 index 0000000000..25acb5a624 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/requirement-status-summary.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { RequirementStatusSummary } from "./requirement-status-summary"; + +describe("RequirementStatusSummary", () => { + it("exposes the complete breakdown from a keyboard-accessible trigger", async () => { + const user = userEvent.setup(); + const entries = Array.from({ length: 13 }, (_, index) => ({ + key: `account-${index}`, + label: `Account ${index + 1}`, + status: index === 0 ? ("FAIL" as const) : ("PASS" as const), + })); + + render(); + + const trigger = screen.getByRole("button", { + name: "Show status breakdown for 13 providers", + }); + trigger.focus(); + expect(trigger).toHaveFocus(); + + await user.click(trigger); + + expect(screen.getByText("Account 1")).toBeVisible(); + expect(screen.getByText("Account 13")).toBeVisible(); + expect(screen.queryByText(/more/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx b/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx index 7780f4f66f..97fb349430 100644 --- a/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx +++ b/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx @@ -2,23 +2,25 @@ import type { ReactNode } from "react"; +import { Button } from "@/components/shadcn/button/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/shadcn/popover"; +import { ScrollArea } from "@/components/shadcn/scroll-area"; import { type FindingStatus, StatusFindingBadge, } from "@/components/shadcn/table/status-finding-badge"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/shadcn/tooltip"; import type { CrossProviderStatus } from "../_types"; export interface RequirementStatusEntry { key: string; - /** Short label shown in the hover breakdown list. */ + /** Short label shown in the breakdown popover. */ label: string; - /** Optional icon rendered before the label in the breakdown list. */ + /** Optional icon rendered before the label in the breakdown popover. */ icon?: ReactNode; status: CrossProviderStatus; } @@ -26,15 +28,14 @@ export interface RequirementStatusEntry { /** Statuses in triage order — failures first, evidence last. */ const STATUS_ORDER: readonly CrossProviderStatus[] = ["FAIL", "MANUAL", "PASS"]; -/** Hover breakdown stays glanceable; beyond this it points at the row's - * drill-down instead of becoming a scrolling list inside a tooltip. */ -const MAX_BREAKDOWN_ROWS = 12; +const SCROLLABLE_BREAKDOWN_MIN_ROWS = 13; /** * Aggregated per-status counts for a requirement row whose column axis has * too many members to chip inline (many accounts of one provider type, or * many provider types). Constant footprint regardless of N: one count badge - * per status present, with the full per-member breakdown on hover. + * per status present, with the full per-member breakdown in an accessible + * popover. */ export const RequirementStatusSummary = ({ entries, @@ -46,15 +47,35 @@ export const RequirementStatusSummary = ({ count: entries.filter((entry) => entry.status === status).length, })).filter(({ count }) => count > 0); - const breakdown = entries.slice(0, MAX_BREAKDOWN_ROWS); - const hidden = entries.length - breakdown.length; + const breakdownList = ( +
+ {entries.map((entry) => ( + + + {entry.icon} + {entry.label} + + + + ))} +
+ ); return ( - - - + + + + + {entries.length >= SCROLLABLE_BREAKDOWN_MIN_ROWS ? ( + {breakdownList} + ) : ( + breakdownList + )} + + ); }; diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/cross-account-mapper-parity.test.tsx b/ui/app/(prowler)/compliance/_lib/__tests__/cross-account-mapper-parity.test.tsx index a01bf4cdf9..704fd36066 100644 --- a/ui/app/(prowler)/compliance/_lib/__tests__/cross-account-mapper-parity.test.tsx +++ b/ui/app/(prowler)/compliance/_lib/__tests__/cross-account-mapper-parity.test.tsx @@ -21,8 +21,17 @@ vi.mock( () => ({ // Render the row name so parity assertions can compare the per-scan // accordion's visible titles against the cross-account builder's. - ComplianceAccordionRequirementTitle: ({ name }: { name: string }) => ( - {name} + ComplianceAccordionRequirementTitle: ({ + name, + type, + }: { + name: string; + type: string; + }) => ( + <> + {type && {type}} + {name} + ), }), ); diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-accordion.tsx b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-accordion.tsx new file mode 100644 index 0000000000..5888f7954b --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-accordion.tsx @@ -0,0 +1,161 @@ +import type { ReactNode } from "react"; + +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; +import type { Control, Framework, Requirement } from "@/types/compliance"; + +interface AggregatedComplianceAccordionOptions { + data: Framework[]; + extras: Map; + renderStatus: (extras: TExtras) => ReactNode; + renderContent: ( + requirement: Requirement, + extras: TExtras, + itemKey: string, + ) => ReactNode; + missingBreakdownMessage: string; +} + +/** Shared mapper-driven hierarchy for provider- and account-axis compliance. + * Axis adapters supply only the status summary and lazy findings content. */ +export const toAggregatedComplianceAccordionItems = ({ + data, + extras, + renderStatus, + renderContent, + missingBreakdownMessage, +}: AggregatedComplianceAccordionOptions): AccordionItemProps[] => { + const requirementItem = ( + requirement: Requirement, + itemKey: string, + rowTitle: string, + ): AccordionItemProps => { + const requirementExtras = extras.get(requirement.name as string); + const requirementType = + typeof requirement.type === "string" ? requirement.type : ""; + + return { + key: itemKey, + title: ( + + ), + content: requirementExtras ? ( + renderContent(requirement, requirementExtras, itemKey) + ) : ( +

+ {missingBreakdownMessage} +

+ ), + items: [], + }; + }; + + const controlItems = ( + control: Control, + categoryName: string, + baseKey: string, + ): AccordionItemProps[] => { + const groupLabel = + control.label && control.label !== categoryName + ? control.label + : undefined; + + if (groupLabel && control.requirements.length > 1) { + return [ + { + key: baseKey, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, requirementIndex) => + requirementItem( + requirement, + `${baseKey}-req-${requirementIndex}`, + requirement.name as string, + ), + ), + }, + ]; + } + + return control.requirements.map((requirement, requirementIndex) => + requirementItem( + requirement, + `${baseKey}-req-${requirementIndex}`, + (groupLabel ?? requirement.name) as string, + ), + ); + }; + + const categoryItems = (frameworkData: Framework): AccordionItemProps[] => + frameworkData.categories.map((category) => ({ + key: `${frameworkData.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.flatMap((control, controlIndex) => + controlItems( + control, + category.name, + `${frameworkData.name}-${category.name}-c${controlIndex}`, + ), + ), + })); + + const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => { + const directRequirements = + (frameworkData as { requirements?: Requirement[] }).requirements ?? []; + if (directRequirements.length > 0) { + return directRequirements.map((requirement, requirementIndex) => + requirementItem( + requirement, + `${frameworkData.name}-req-${requirementIndex}`, + requirement.name as string, + ), + ); + } + return categoryItems(frameworkData); + }; + + if (data.length > 1) { + return data.map((frameworkData) => ({ + key: frameworkData.name, + title: ( + + ), + content: "", + items: frameworkItems(frameworkData), + })); + } + + return data.flatMap(frameworkItems); +}; diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts new file mode 100644 index 0000000000..6c8303dc83 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts @@ -0,0 +1,296 @@ +import * as Sentry from "@sentry/nextjs"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; +import { hasActionError, type ActionErrorResult } from "@/lib/action-errors"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { SentryErrorSource, SentryErrorType } from "@/sentry"; + +import type { LatestCrossProviderPdf } from "../_types"; +import { + CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, +} from "../_types"; + +const AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS = 30_000; + +interface PdfEndpointErrorBody { + errors?: Array<{ detail?: string }>; + error?: string; + message?: string; +} + +type AggregatedComplianceOverviewResult = + | { + status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS; + response: TResponse; + } + | { + status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR; + result: ActionErrorResult; + } + | { + status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR; + message: string; + }; + +const captureRequestFailure = ( + error: unknown, + operation: string, + timedOut: boolean, +) => { + const capturedError = + error instanceof Error ? error : new Error(getErrorMessage(error)); + Sentry.captureException(capturedError, { + tags: { + error_source: SentryErrorSource.SERVER_ACTION, + error_type: SentryErrorType.SERVER_ACTION_ERROR, + request_timed_out: timedOut, + }, + level: "error", + contexts: { + api_request: { operation }, + }, + }); +}; + +/** Fetch wrapper used by every aggregated-compliance endpoint. It keeps the + * static route template in telemetry, aborts stalled upstream requests, and + * always clears its timer once the request settles. */ +const fetchAggregatedCompliance = async ( + url: URL, + init: RequestInit, + operation: string, +): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort( + new Error( + `Aggregated compliance request timed out after ${AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS}ms`, + ), + ); + }, AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS); + + try { + return await fetch(url.toString(), { + ...init, + signal: controller.signal, + }); + } catch (error) { + captureRequestFailure(error, operation, controller.signal.aborted); + throw error; + } finally { + clearTimeout(timeoutId); + } +}; + +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); + + if (response.status >= 500) { + Sentry.captureException( + new Error( + `Aggregated compliance 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 + ); +}; + +export const getAggregatedComplianceOverview = async ( + url: URL, + operation: string, +): Promise> => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetchAggregatedCompliance( + url, + { headers }, + operation, + ); + 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 TResponse, + }; + } catch (error) { + console.error("Error fetching aggregated compliance overview:", error); + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + }; + } +}; + +export const generateAggregatedCompliancePdf = async ( + url: URL, + operation: string, +): Promise<{ taskId: string } | { error: string }> => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetchAggregatedCompliance( + url, + { method: "POST", headers }, + operation, + ); + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to start PDF generation. Contact support if the issue continues.", + operation, + ), + ); + } + + 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) }; + } +}; + +export const getAggregatedCompliancePdfBinary = async ({ + url, + operation, + defaultFilename, +}: { + url: URL; + operation: string; + defaultFilename: string; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetchAggregatedCompliance( + url, + { headers }, + operation, + ); + + 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.", + operation, + ), + ); + } + + const contentDisposition = + response.headers.get("content-disposition") || ""; + const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + const filename = filenameMatch?.[1] || defaultFilename; + const arrayBuffer = await response.arrayBuffer(); + + return { + success: true, + data: Buffer.from(arrayBuffer).toString("base64"), + filename, + }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +export const getLatestAggregatedCompliancePdf = async ( + url: URL, + operation: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetchAggregatedCompliance( + url, + { headers }, + operation, + ); + + if (response.status === 404) return null; + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to check for an existing PDF report.", + operation, + ), + ); + } + + 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) { + console.error("Error checking for an aggregated compliance PDF:", error); + return null; + } +}; diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts new file mode 100644 index 0000000000..2fda5da801 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts @@ -0,0 +1,28 @@ +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import type { Framework, RequirementsTotals } from "@/types/compliance"; + +export const getAggregatedRequirementsTotals = ( + data: Framework[], +): RequirementsTotals => + data.reduce( + (totals, framework) => ({ + pass: totals.pass + framework.pass, + fail: totals.fail + framework.fail, + manual: totals.manual + framework.manual, + }), + { pass: 0, fail: 0, manual: 0 }, + ); + +export const getAggregatedInitialExpandedKeys = ( + data: Framework[], + accordionItems: AccordionItemProps[], + targetSection?: string, +): string[] => { + if (!targetSection) return []; + + const candidates = new Set( + data.map((framework) => `${framework.name}-${targetSection}`), + ); + const match = accordionItems.find((item) => candidates.has(item.key)); + return match ? [match.key] : []; +}; diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-pdf.tsx b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-pdf.tsx new file mode 100644 index 0000000000..5a0ffa3da8 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-pdf.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { downloadFile } from "@/lib/helper"; +import type { TaskKindHandler } from "@/store/task-watcher/store"; + +const normalizeScopeValue = (value: string | string[] | undefined) => { + if (Array.isArray(value)) return [...value].sort(); + return ( + value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean) + .sort() + .join(",") ?? "" + ); +}; + +export const buildAggregatedCompliancePdfTaskScope = ( + values: Record, +): string => + JSON.stringify( + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + normalizeScopeValue(value), + ]), + ), + ); + +export const downloadAggregatedCompliancePdf = async ({ + taskId, + getPdfBinary, + axisLabel, +}: { + taskId: string; + getPdfBinary: (taskId: string) => Promise; + axisLabel: string; +}): Promise => { + try { + const result = await getPdfBinary(taskId); + await downloadFile( + result, + "application/pdf", + `The ${axisLabel} compliance PDF has been downloaded successfully.`, + toast, + ); + } catch { + toast({ + variant: "destructive", + title: "Download failed", + description: "Could not fetch the report. Please try again later.", + }); + } +}; + +export const createAggregatedCompliancePdfHandler = ({ + axisLabel, + downloadPdf, +}: { + axisLabel: string; + downloadPdf: (taskId: string) => Promise; +}): TaskKindHandler => ({ + onReady: (task) => { + toast({ + title: "Compliance report ready", + description: task.meta.reportLabel + ? `The ${task.meta.reportLabel} ${axisLabel} PDF has been generated.` + : `The ${axisLabel} compliance PDF has been generated.`, + action: ( + downloadPdf(task.taskId)} + > + Download + + ), + }); + }, + onError: (task) => { + toast({ + variant: "destructive", + title: "Report generation failed", + description: + task.error || + `The ${axisLabel} PDF could not be generated. Try again later.`, + }); + }, +}); diff --git a/ui/app/(prowler)/compliance/_lib/cross-account-accordion.tsx b/ui/app/(prowler)/compliance/_lib/cross-account-accordion.tsx index 87894a8c51..500552dced 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-account-accordion.tsx +++ b/ui/app/(prowler)/compliance/_lib/cross-account-accordion.tsx @@ -1,12 +1,5 @@ -import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; -import { InfoTooltip } from "@/components/shadcn/info-field/info-field"; -import { - type FindingStatus, - StatusFindingBadge, -} from "@/components/shadcn/table/status-finding-badge"; -import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons"; -import type { Control, Framework, Requirement } from "@/types/compliance"; +import type { Framework } from "@/types/compliance"; import { CrossAccountRequirementContent } from "../_components/cross-account-requirement-content"; import { RequirementAccountChips } from "../_components/requirement-account-chips"; @@ -15,206 +8,32 @@ import type { CrossAccountRequirementExtras, } from "../_types"; -/** - * Accordion assembly for the cross-account detail — the account-axis sibling - * of `toCrossProviderAccordionItems` (same section key scheme, so `?section=` - * deep links behave identically). Each requirement's status is shown once, - * on the title row: via the per-account chips when a breakdown exists, or a - * single roll-up badge as a fallback. `extras` is the map produced by - * `buildAccountExtrasMap`, keyed by the mapper-composed requirement name. - * - * Row titles and hierarchy mirror what each mapper's OWN per-scan - * `toAccordionItems` renders, so a framework looks the same here as in the - * Single Scan view: - * - CSA/CIS-Controls/DORA-style mappers put every requirement (already - * richly named `id - title`) under one control labeled like its category - * → flatten to requirement rows. - * - The CIS mapper creates one control PER requirement whose label carries - * the full `id - description` while `requirement.name` is the bare id - * → use the control label as the row title. - * - ENS-style mappers group frameworks (marcos) above categories and - * several requirements under a labeled control → keep both extra levels, - * like per-scan does, and show each requirement's type chip - * (requisito/recomendación/refuerzo). - */ +import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion"; + export const toCrossAccountAccordionItems = ( data: Framework[], extras: Map, framework: string, accountMeta: CrossAccountAccountRef[], -): AccordionItemProps[] => { - const requirementItem = ( - requirement: Requirement, - itemKey: string, - rowTitle: string, - ): AccordionItemProps => { - const requirementExtras = extras.get(requirement.name as string); - const requirementType = - typeof requirement.type === "string" ? requirement.type : ""; - - return { - key: itemKey, - title: ( -
- {/* Same left-side composition as the per-scan - ComplianceAccordionRequirementTitle (type chip + name + - invalid-config note); only the right side differs (per-account - chips instead of one status badge). */} -
- {requirementType && ( - - {requirementType} - - )} - {rowTitle} - {requirement.invalid_config && ( - - )} -
- {requirementExtras ? ( - - ) : ( - - )} -
- ), - // Explicit key on the content element, matching the per-scan mappers - // (csa.tsx et al.): these elements travel to the client inside the - // serialized `items` array, where React's Flight layer warns about - // un-keyed elements ("Each child in a list…"). - content: requirementExtras ? ( - - ) : ( -

- No per-account breakdown is available for this requirement. -

- ), - items: [], - }; - }; - - const controlItems = ( - control: Control, - categoryName: string, - baseKey: string, - ): AccordionItemProps[] => { - // A label that just repeats the category (the flat-mapper convention) - // carries no information; a distinct one is the mapper's richer title. - const groupLabel = - control.label && control.label !== categoryName - ? control.label - : undefined; - - if (groupLabel && control.requirements.length > 1) { - // ENS-style group: keep the control as its own accordion level, the - // way that mapper's per-scan toAccordionItems renders it. - return [ - { - key: baseKey, - title: ( - - ), - content: "", - items: control.requirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${baseKey}-req-${reqIndex}`, - requirement.name as string, - ), - ), - }, - ]; - } - - return control.requirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${baseKey}-req-${reqIndex}`, - // Single-requirement controls (CIS style) carry the full - // `id - description` in the control label while requirement.name is - // the bare id — prefer the richer title, like the per-scan view. - (groupLabel ?? requirement.name) as string, - ), - ); - }; - - const categoryItems = (frameworkData: Framework): AccordionItemProps[] => - frameworkData.categories.map((category) => ({ - key: `${frameworkData.name}-${category.name}`, - title: ( - - ), - content: "", - // The control index participates in the key: a category can hold - // several controls whose requirement lists all start at index 0, so - // keying on the requirement index alone collides across controls - // (React "two children with the same key"). - items: category.controls.flatMap((control, controlIndex) => - controlItems( - control, - category.name, - `${frameworkData.name}-${category.name}-c${controlIndex}`, - ), - ), - })); - - const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => { - // Flat generic-mapper structure (e.g. GDPR): requirements hang directly - // off the framework with no categories — that mapper's per-scan - // toAccordionItems renders them as top-level rows, so mirror it. - const directRequirements = - (frameworkData as { requirements?: Requirement[] }).requirements ?? []; - if (directRequirements.length > 0) { - return directRequirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${frameworkData.name}-req-${reqIndex}`, - requirement.name as string, - ), - ); - } - return categoryItems(frameworkData); - }; - - // Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the - // framework as the top accordion level, exactly like that mapper's own - // per-scan toAccordionItems; single-framework data starts at categories. - if (data.length > 1) { - return data.map((frameworkData) => ({ - key: frameworkData.name, - title: ( - - ), - content: "", - items: frameworkItems(frameworkData), - })); - } - - return data.flatMap(frameworkItems); -}; +): AccordionItemProps[] => + toAggregatedComplianceAccordionItems({ + data, + extras, + renderStatus: (requirementExtras) => ( + + ), + renderContent: (requirement, requirementExtras, itemKey) => ( + + ), + missingBreakdownMessage: + "No per-account breakdown is available for this requirement.", + }); diff --git a/ui/app/(prowler)/compliance/_lib/cross-account-pdf.tsx b/ui/app/(prowler)/compliance/_lib/cross-account-pdf.tsx index 7286334bb6..d144fb3bd7 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-account-pdf.tsx +++ b/ui/app/(prowler)/compliance/_lib/cross-account-pdf.tsx @@ -1,85 +1,37 @@ "use client"; -import { toast, ToastAction } from "@/components/shadcn/toast"; -import { downloadFile } from "@/lib/helper"; -import type { TaskKindHandler } from "@/store/task-watcher/store"; - import { getCrossAccountPdfBinary } from "../_actions/cross-account"; import type { CrossAccountApiFilters } from "../_types"; +import { + buildAggregatedCompliancePdfTaskScope, + createAggregatedCompliancePdfHandler, + downloadAggregatedCompliancePdf, +} from "./aggregated-compliance-pdf"; + export const CROSS_ACCOUNT_PDF_TASK_KIND = "cross-account-pdf"; -const normalizeCommaSeparatedFilter = (value?: string): string => - value - ?.split(",") - .map((item) => item.trim()) - .filter(Boolean) - .sort() - .join(",") ?? ""; - -/** Stable identity for the exact cross-account view a PDF represents. */ export const buildCrossAccountPdfTaskScope = ( complianceId: string, providerType: string, filters: CrossAccountApiFilters, ): string => - JSON.stringify({ + buildAggregatedCompliancePdfTaskScope({ complianceId, providerType, - scanIds: [...(filters.scanIds ?? [])].sort(), - providerIds: normalizeCommaSeparatedFilter(filters.providerIds), - providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups), + scanIds: filters.scanIds, + providerIds: filters.providerIds, + providerGroups: filters.providerGroups, }); -/** Fetches the finished cross-account PDF and hands it to the browser — - * same never-rejects contract as `downloadCrossProviderPdf`. */ -export const downloadCrossAccountPdf = async ( - taskId: string, -): Promise => { - try { - const result = await getCrossAccountPdfBinary(taskId); - await downloadFile( - result, - "application/pdf", - "The cross-account compliance PDF has been downloaded successfully.", - toast, - ); - } catch { - toast({ - variant: "destructive", - title: "Download failed", - description: "Could not fetch the report. Please try again later.", - }); - } -}; +export const downloadCrossAccountPdf = (taskId: string): Promise => + downloadAggregatedCompliancePdf({ + taskId, + getPdfBinary: getCrossAccountPdfBinary, + axisLabel: "cross-account", + }); -/** Completion handler for cross-account PDF generation tasks, fired by the - * generic task watcher — survives navigation and hard reloads like its - * cross-provider sibling. */ -export const crossAccountPdfHandler: TaskKindHandler = { - onReady: (task) => { - toast({ - title: "Compliance report ready", - description: task.meta.reportLabel - ? `The ${task.meta.reportLabel} cross-account PDF has been generated.` - : "The cross-account compliance PDF has been generated.", - action: ( - downloadCrossAccountPdf(task.taskId)} - > - Download - - ), - }); - }, - onError: (task) => { - toast({ - variant: "destructive", - title: "Report generation failed", - description: - task.error || - "The cross-account PDF could not be generated. Try again later.", - }); - }, -}; +export const crossAccountPdfHandler = createAggregatedCompliancePdfHandler({ + axisLabel: "cross-account", + downloadPdf: downloadCrossAccountPdf, +}); diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-accordion.tsx b/ui/app/(prowler)/compliance/_lib/cross-provider-accordion.tsx index 87a3079927..c646fc6f4d 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-provider-accordion.tsx +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-accordion.tsx @@ -1,201 +1,31 @@ -import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; -import { InfoTooltip } from "@/components/shadcn/info-field/info-field"; -import { - type FindingStatus, - StatusFindingBadge, -} from "@/components/shadcn/table/status-finding-badge"; -import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons"; -import type { Control, Framework, Requirement } from "@/types/compliance"; +import type { Framework } from "@/types/compliance"; import { CrossProviderRequirementContent } from "../_components/cross-provider-requirement-content"; import { RequirementProviderChips } from "../_components/requirement-provider-chips"; import type { CrossProviderRequirementExtras } from "../_types"; -/** - * Accordion assembly for the cross-provider detail. Mirrors the per-scan - * mappers' `toAccordionItems` (same section key scheme, so `?section=` deep - * links behave identically) but swaps the per-scan findings content for the - * per-provider fan-out. Each requirement's status is shown once, on the same - * row as the name and the expand chevron: via the per-provider chips when a - * breakdown exists, or a single roll-up badge as a fallback. `extras` is the - * map produced by `buildRequirementExtrasMap`, keyed by the mapper-composed - * requirement name. - * - * Row titles and hierarchy mirror what each mapper's OWN per-scan - * `toAccordionItems` renders (see the cross-account sibling for the full - * rationale): flat mappers keep requirement rows, single-requirement - * controls with a distinct label (CIS style) use that richer label as the - * row title, labeled multi-requirement controls (ENS style) keep the - * control as a nested accordion level, and multi-framework data keeps the - * framework as the top level. - */ +import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion"; + export const toCrossProviderAccordionItems = ( data: Framework[], extras: Map, framework: string, -): AccordionItemProps[] => { - const requirementItem = ( - requirement: Requirement, - itemKey: string, - rowTitle: string, - ): AccordionItemProps => { - const requirementExtras = extras.get(requirement.name as string); - const requirementType = - typeof requirement.type === "string" ? requirement.type : ""; - - return { - key: itemKey, - title: ( -
- {/* Same left-side composition as the per-scan - ComplianceAccordionRequirementTitle (type chip + name + - invalid-config note); only the right side differs (per-provider - chips instead of one status badge). */} -
- {requirementType && ( - - {requirementType} - - )} - {rowTitle} - {requirement.invalid_config && ( - - )} -
- {requirementExtras ? ( - - ) : ( - - )} -
- ), - // Explicit key on the content element, matching the per-scan mappers - // (csa.tsx et al.): these elements travel to the client inside the - // serialized `items` array, where React's Flight layer warns about - // un-keyed elements ("Each child in a list…"). - content: requirementExtras ? ( - - ) : ( -

- No per-provider breakdown is available for this requirement. -

- ), - items: [], - }; - }; - - const controlItems = ( - control: Control, - categoryName: string, - baseKey: string, - ): AccordionItemProps[] => { - const groupLabel = - control.label && control.label !== categoryName - ? control.label - : undefined; - - if (groupLabel && control.requirements.length > 1) { - return [ - { - key: baseKey, - title: ( - - ), - content: "", - items: control.requirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${baseKey}-req-${reqIndex}`, - requirement.name as string, - ), - ), - }, - ]; - } - - return control.requirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${baseKey}-req-${reqIndex}`, - (groupLabel ?? requirement.name) as string, - ), - ); - }; - - const categoryItems = (frameworkData: Framework): AccordionItemProps[] => - frameworkData.categories.map((category) => ({ - key: `${frameworkData.name}-${category.name}`, - title: ( - - ), - content: "", - // The control index participates in the key: a category can hold - // several controls whose requirement lists all start at index 0, so - // keying on the requirement index alone collides across controls - // (React "two children with the same key"). - items: category.controls.flatMap((control, controlIndex) => - controlItems( - control, - category.name, - `${frameworkData.name}-${category.name}-c${controlIndex}`, - ), - ), - })); - - const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => { - // Flat generic-mapper structure (e.g. GDPR): requirements hang directly - // off the framework with no categories — that mapper's per-scan - // toAccordionItems renders them as top-level rows, so mirror it. - const directRequirements = - (frameworkData as { requirements?: Requirement[] }).requirements ?? []; - if (directRequirements.length > 0) { - return directRequirements.map((requirement, reqIndex) => - requirementItem( - requirement, - `${frameworkData.name}-req-${reqIndex}`, - requirement.name as string, - ), - ); - } - return categoryItems(frameworkData); - }; - - // Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the - // framework as the top accordion level, exactly like that mapper's own - // per-scan toAccordionItems; single-framework data starts at categories. - if (data.length > 1) { - return data.map((frameworkData) => ({ - key: frameworkData.name, - title: ( - - ), - content: "", - items: frameworkItems(frameworkData), - })); - } - - return data.flatMap(frameworkItems); -}; +): AccordionItemProps[] => + toAggregatedComplianceAccordionItems({ + data, + extras, + renderStatus: (requirementExtras) => ( + + ), + renderContent: (requirement, requirementExtras, itemKey) => ( + + ), + missingBreakdownMessage: + "No per-provider breakdown is available for this requirement.", + }); diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-pdf.tsx b/ui/app/(prowler)/compliance/_lib/cross-provider-pdf.tsx index f60110c569..7a63b1f32c 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-provider-pdf.tsx +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-pdf.tsx @@ -1,91 +1,36 @@ "use client"; -import { toast, ToastAction } from "@/components/shadcn/toast"; -import { downloadFile } from "@/lib/helper"; -import type { TaskKindHandler } from "@/store/task-watcher/store"; - import { getCrossProviderPdfBinary } from "../_actions/cross-provider"; import type { CrossProviderApiFilters } from "../_types"; +import { + buildAggregatedCompliancePdfTaskScope, + createAggregatedCompliancePdfHandler, + downloadAggregatedCompliancePdf, +} from "./aggregated-compliance-pdf"; + export const CROSS_PROVIDER_PDF_TASK_KIND = "cross-provider-pdf"; -const normalizeCommaSeparatedFilter = (value?: string): string => - value - ?.split(",") - .map((item) => item.trim()) - .filter(Boolean) - .sort() - .join(",") ?? ""; - -/** Stable identity for the exact cross-provider view a PDF represents. */ export const buildCrossProviderPdfTaskScope = ( complianceId: string, filters: CrossProviderApiFilters, ): string => - JSON.stringify({ + buildAggregatedCompliancePdfTaskScope({ complianceId, - scanIds: [...(filters.scanIds ?? [])].sort(), - providerTypes: normalizeCommaSeparatedFilter(filters.providerTypes), - providerIds: normalizeCommaSeparatedFilter(filters.providerIds), - providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups), + scanIds: filters.scanIds, + providerTypes: filters.providerTypes, + providerIds: filters.providerIds, + providerGroups: filters.providerGroups, }); -/** Fetches the finished cross-provider PDF and hands it to the browser, - * reusing the shared base64→blob download + toast handling. Never rejects: - * it is fired from toast actions and dropdown items whose rejections would - * otherwise vanish unhandled. */ -export const downloadCrossProviderPdf = async ( - taskId: string, -): Promise => { - try { - const result = await getCrossProviderPdfBinary(taskId); - await downloadFile( - result, - "application/pdf", - "The cross-provider compliance PDF has been downloaded successfully.", - toast, - ); - } catch { - // The action catches API failures itself; this guards the server-action - // RPC (e.g. a network drop between browser and Next server). - toast({ - variant: "destructive", - title: "Download failed", - description: "Could not fetch the report. Please try again later.", - }); - } -}; +export const downloadCrossProviderPdf = (taskId: string): Promise => + downloadAggregatedCompliancePdf({ + taskId, + getPdfBinary: getCrossProviderPdfBinary, + axisLabel: "cross-provider", + }); -/** - * Completion handler for cross-provider PDF generation tasks. Fired by the - * generic task watcher (`@/store/task-watcher`) whenever a tracked task of - * this kind settles — including after client-side navigation (module-scope - * poll loop) or a hard reload (persisted store + `TaskPollingWatcher`). - */ -export const crossProviderPdfHandler: TaskKindHandler = { - onReady: (task) => { - toast({ - title: "Compliance report ready", - description: task.meta.reportLabel - ? `The ${task.meta.reportLabel} cross-provider PDF has been generated.` - : "The cross-provider compliance PDF has been generated.", - action: ( - downloadCrossProviderPdf(task.taskId)} - > - Download - - ), - }); - }, - onError: (task) => { - toast({ - variant: "destructive", - title: "Report generation failed", - description: - task.error || - "The cross-provider PDF could not be generated. Try again later.", - }); - }, -}; +export const crossProviderPdfHandler = createAggregatedCompliancePdfHandler({ + axisLabel: "cross-provider", + downloadPdf: downloadCrossProviderPdf, +}); diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx index aeae6bc03c..fa2667f848 100644 --- a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -1,5 +1,11 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@/components/shadcn/badge/badge"; import { InfoTooltip } from "@/components/shadcn/info-field/info-field"; -import { FindingStatus, StatusFindingBadge } from "@/components/shadcn/table"; +import { + type FindingStatus, + StatusFindingBadge, +} from "@/components/shadcn/table/status-finding-badge"; import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons"; interface ComplianceAccordionRequirementTitleProps { @@ -7,6 +13,7 @@ interface ComplianceAccordionRequirementTitleProps { name: string; status: FindingStatus; invalidConfig?: boolean; + statusContent?: ReactNode; } export const ComplianceAccordionRequirementTitle = ({ @@ -14,19 +21,20 @@ export const ComplianceAccordionRequirementTitle = ({ name, status, invalidConfig = false, + statusContent, }: ComplianceAccordionRequirementTitleProps) => { return (
{type && ( - + {type} - + )} {name} {invalidConfig && }
- + {statusContent ?? }
); }; diff --git a/ui/components/shadcn/card/card.test.tsx b/ui/components/shadcn/card/card.test.tsx index 8d2e1ec7fa..37fa8d3508 100644 --- a/ui/components/shadcn/card/card.test.tsx +++ b/ui/components/shadcn/card/card.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { CardHeader } from "./card"; +import { Card, CardHeader } from "./card"; describe("CardHeader", () => { it("does not add vertical margin by default", () => { @@ -15,3 +15,15 @@ describe("CardHeader", () => { expect(header).not.toHaveClass("mb-6"); }); }); + +describe("Card", () => { + it("provides the shared interactive treatment without call-site classes", () => { + render(Framework); + + expect(screen.getByText("Framework")).toHaveClass( + "cursor-pointer", + "transition-shadow", + "hover:shadow-md", + ); + }); +}); diff --git a/ui/components/shadcn/card/card.tsx b/ui/components/shadcn/card/card.tsx index ee1174d061..c6264d9805 100644 --- a/ui/components/shadcn/card/card.tsx +++ b/ui/components/shadcn/card/card.tsx @@ -31,6 +31,10 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", { xl: "p-8", none: "p-0", }, + interactive: { + true: "cursor-pointer transition-shadow hover:shadow-md", + false: "", + }, }, compoundVariants: [ { @@ -57,6 +61,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", { defaultVariants: { variant: "default", padding: "default", + interactive: false, }, }); @@ -64,11 +69,17 @@ interface CardProps extends React.ComponentProps<"div">, VariantProps {} -function Card({ className, variant, padding, ...props }: CardProps) { +function Card({ + className, + variant, + padding, + interactive, + ...props +}: CardProps) { return (
); diff --git a/ui/components/shadcn/progress.test.tsx b/ui/components/shadcn/progress.test.tsx new file mode 100644 index 0000000000..a1687ad313 --- /dev/null +++ b/ui/components/shadcn/progress.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Progress } from "./progress"; + +describe("Progress", () => { + it("provides semantic indicator variants", () => { + render(); + + expect( + screen.getByRole("progressbar", { name: "Score" }).firstChild, + ).toHaveClass("bg-bg-warning"); + }); +}); diff --git a/ui/components/shadcn/progress.tsx b/ui/components/shadcn/progress.tsx index 3a549f4004..4423edcd3d 100644 --- a/ui/components/shadcn/progress.tsx +++ b/ui/components/shadcn/progress.tsx @@ -7,12 +7,21 @@ import { cn } from "@/lib/utils"; interface ProgressProps extends ComponentProps { indicatorClassName?: string; + variant?: "default" | "success" | "warning" | "danger"; } +const indicatorVariants = { + default: "bg-button-primary", + success: "bg-bg-pass", + warning: "bg-bg-warning", + danger: "bg-bg-fail", +} as const; + function Progress({ className, value = 0, indicatorClassName, + variant = "default", ...props }: ProgressProps) { const normalizedValue = value ?? 0; @@ -30,7 +39,8 @@ function Progress({ { + it("provides a medium viewport height", () => { + render(Content); + + expect( + screen.getByText("Content").parentElement?.parentElement, + ).toHaveClass("h-72"); + }); +}); diff --git a/ui/components/shadcn/scroll-area/scroll-area.tsx b/ui/components/shadcn/scroll-area/scroll-area.tsx index e64e596862..ac1e52cd11 100644 --- a/ui/components/shadcn/scroll-area/scroll-area.tsx +++ b/ui/components/shadcn/scroll-area/scroll-area.tsx @@ -5,13 +5,22 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +interface ScrollAreaProps + extends React.ComponentPropsWithoutRef { + size?: "auto" | "md"; +} + const ScrollArea = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + ScrollAreaProps +>(({ className, children, size = "auto", ...props }, ref) => (