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) => (