feat(ui): add cross-provider compliance view (#11912)

This commit is contained in:
Alejandro Bailo
2026-07-10 17:34:53 +02:00
committed by GitHub
parent a0a0883578
commit dbdbd8a379
49 changed files with 4801 additions and 135 deletions
@@ -1,3 +1,5 @@
import { ChevronDownIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import {
@@ -24,13 +26,17 @@ import {
TopFailedSectionsCardSkeleton,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
} from "@/lib/compliance/compliance-report-types";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
import {
AttributesData,
Framework,
@@ -38,38 +44,84 @@ import {
} from "@/types/compliance";
import { ScanEntity } from "@/types/scans";
interface ComplianceDetailSearchParams {
complianceId: string;
version?: string;
scanId?: string;
section?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
pageSize?: string;
}
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks";
import { buildSearchParamsKey } from "../_lib/search-params-key";
const getSingleSearchParam = (
value: string | string[] | undefined,
): string | undefined =>
typeof value === "string" && value ? value : undefined;
export default async function ComplianceDetail({
params,
searchParams,
}: {
params: Promise<{ compliancetitle: string }>;
searchParams: Promise<ComplianceDetailSearchParams>;
searchParams: Promise<SearchParamsProps>;
}) {
const { compliancetitle } = await params;
const resolvedSearchParams = await searchParams;
const { complianceId, version, scanId, section } = resolvedSearchParams;
const regionFilter = resolvedSearchParams["filter[region__in]"];
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const complianceId = getSingleSearchParam(resolvedSearchParams.complianceId);
const version = getSingleSearchParam(resolvedSearchParams.version);
const scanId = getSingleSearchParam(resolvedSearchParams.scanId);
const section = getSingleSearchParam(resolvedSearchParams.section);
const mode = getSingleSearchParam(resolvedSearchParams.mode);
if (!complianceId) {
notFound();
}
// Cross-provider mode replaces the per-scan pipeline with the universal
// roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so
// the route is blocked in OSS the same way the compliance tab is.
if (mode === "cross-provider") {
if (!isCloud()) {
redirect("/compliance");
}
const framework = resolveCrossProviderFramework(
complianceId,
compliancetitle,
);
if (!framework) {
notFound();
}
const crossProviderTitle = framework.title.split("-").join(" ");
return (
<ContentLayout title={`${crossProviderTitle} - ${framework.version}`}>
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
fallback={
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCardSkeleton />
<TopFailedSectionsCardSkeleton />
</div>
<SkeletonAccordion />
</div>
}
>
<CrossProviderDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
searchParams={resolvedSearchParams}
targetSection={section}
/>
</Suspense>
</ContentLayout>
);
}
const regionFilter = getSingleSearchParam(
resolvedSearchParams["filter[region__in]"],
);
const cisProfileFilter = getSingleSearchParam(
resolvedSearchParams["filter[cis_profile_level]"],
);
const logoPath = getComplianceIcon(compliancetitle);
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
const paramsForKey = Object.fromEntries(
Object.entries(resolvedSearchParams).filter(
([key]) => key !== "page" && key !== "pageSize",
),
);
const searchParamsKey = JSON.stringify(paramsForKey);
const searchParamsKey = buildSearchParamsKey(resolvedSearchParams);
const formattedTitle = compliancetitle.split("-").join(" ");
const pageTitle = version
@@ -176,33 +228,45 @@ export default async function ComplianceDetail({
return (
<ContentLayout title={finalPageTitle}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<ComplianceHeader
scans={[]}
uniqueRegions={uniqueRegions}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={logoPath}
complianceTitle={compliancetitle}
selectedScan={selectedScan}
/>
</div>
{selectedScanId && (
<div className="mb-4 flex-shrink-0 self-end sm:mb-0 sm:self-start sm:pt-1">
<ComplianceDownloadContainer
scanId={selectedScanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
attributesData?.data?.[0]?.attributes?.framework,
complianceId,
latestCisIds.has(complianceId),
)}
{/* Header card — same surface as the cross-provider detail: scan info
and filters on the left, report actions and framework logo on the
right (lighthouse-settings card pattern). */}
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<ComplianceHeader
scans={[]}
uniqueRegions={uniqueRegions}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={logoPath}
complianceTitle={compliancetitle}
selectedScan={selectedScan}
/>
</div>
)}
</div>
{selectedScanId && (
<div className="mb-4 flex-shrink-0 self-end sm:mb-0 sm:self-start sm:pt-1">
<ComplianceDownloadContainer
scanId={selectedScanId}
complianceId={complianceId}
presentation="dropdown"
dropdownTrigger={
<Button variant="outline">
Report
<ChevronDownIcon />
</Button>
}
reportType={getReportTypeForCompliance(
attributesData?.data?.[0]?.attributes?.framework,
complianceId,
latestCisIds.has(complianceId),
)}
/>
</div>
)}
</div>
</Card>
<Suspense
key={searchParamsKey}
@@ -349,7 +413,6 @@ const SSRComplianceContent = async ({
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</div>
<div className="bg-border-neutral-primary h-1 w-full rounded-full" />
<ClientAccordionWrapper
hideExpandButton={complianceId.includes("mitre_attack")}
items={accordionItems}
@@ -0,0 +1,349 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiResponseMock,
captureExceptionMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
captureExceptionMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
}));
import {
generateCrossProviderPdf,
getCrossProviderComplianceOverview,
getCrossProviderPdfBinary,
getLatestCrossProviderPdf,
} from "./cross-provider";
const lastFetchUrl = (): URL => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
return new URL(String(call[0]));
};
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/vnd.api+json" },
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(jsonResponse({ data: null }));
getAuthHeadersMock.mockResolvedValue({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
});
handleApiResponseMock.mockResolvedValue({ data: null });
});
describe("getCrossProviderComplianceOverview", () => {
it("requests the overview with the required compliance_id filter", async () => {
await getCrossProviderComplianceOverview({ complianceId: "csa_ccm_4.0" });
const url = lastFetchUrl();
expect(url.pathname).toBe("/api/v1/cross-provider-compliance-overviews");
expect(url.searchParams.get("filter[compliance_id]")).toBe("csa_ccm_4.0");
expect(Array.from(url.searchParams.keys())).toEqual([
"filter[compliance_id]",
]);
});
it("serializes optional filters and joins scan ids with commas", async () => {
await getCrossProviderComplianceOverview({
complianceId: "dora_2022_2554",
filters: {
scanIds: ["scan-1", "scan-2"],
providerTypes: "aws,gcp",
providerIds: "prov-1",
providerGroups: "group-1",
},
});
const url = lastFetchUrl();
expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2");
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp");
expect(url.searchParams.get("filter[provider_id__in]")).toBe("prov-1");
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
expect(url.searchParams.has("filter[region__in]")).toBe(false);
});
it("wraps successful overview responses", async () => {
const payload = { data: { id: "csa_ccm_4.0" } };
handleApiResponseMock.mockResolvedValue(payload);
const result = await getCrossProviderComplianceOverview({
complianceId: "csa_ccm_4.0",
});
expect(result).toEqual({ status: "success", response: payload });
});
it("returns the shared action error shape for payment-required responses", async () => {
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 402));
handleApiResponseMock.mockResolvedValue({
error: "Payment required.",
status: 402,
});
const result = await getCrossProviderComplianceOverview({
complianceId: "csa_ccm_4.0",
});
expect(result).toEqual({
status: "action-error",
result: { error: "Payment required.", status: 402 },
});
});
it("returns a load error when the network call throws", async () => {
fetchMock.mockRejectedValue(new Error("boom"));
const result = await getCrossProviderComplianceOverview({
complianceId: "csa_ccm_4.0",
});
expect(result).toEqual({
status: "load-error",
message:
"Could not load cross-provider compliance data. Try again later.",
});
});
});
describe("generateCrossProviderPdf", () => {
it("POSTs pdf with filters and an optional report name", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202),
);
const result = await generateCrossProviderPdf({
complianceId: "csa_ccm_4.0",
filters: { scanIds: ["scan-1"], providerTypes: "aws" },
reportName: "quarterly.pdf",
});
expect(result).toEqual({ taskId: "task-1" });
const call = fetchMock.mock.calls.at(-1);
expect((call?.[1] as RequestInit).method).toBe("POST");
const url = lastFetchUrl();
expect(url.pathname).toBe(
"/api/v1/cross-provider-compliance-overviews/pdf",
);
expect(url.searchParams.get("filter[compliance_id]")).toBe("csa_ccm_4.0");
expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1");
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
expect(url.searchParams.get("report_name")).toBe("quarterly.pdf");
expect(url.searchParams.has("only_failed")).toBe(false);
expect(url.searchParams.has("include_manual")).toBe(false);
});
it("omits the optional report name when not provided", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202),
);
await generateCrossProviderPdf({ complianceId: "csa_ccm_4.0" });
const url = lastFetchUrl();
expect(url.searchParams.has("report_name")).toBe(false);
});
it("returns the API error detail when generation fails", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ errors: [{ detail: "No compatible scans." }] }, 422),
);
const result = await generateCrossProviderPdf({
complianceId: "csa_ccm_4.0",
});
expect(result).toEqual({ error: "No compatible scans." });
});
it("reports 5xx failures to Sentry with the static route template only", async () => {
fetchMock.mockResolvedValue(jsonResponse({}, 500));
const result = await generateCrossProviderPdf({
complianceId: "csa_ccm_4.0",
reportName: "secret-name.pdf",
});
expect(result).toEqual({ error: "Generic server error." });
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]);
expect(serialized).toContain("cross-provider-compliance-overviews/pdf");
expect(serialized).not.toContain("secret-name");
});
it("falls back to the static message for text/html error pages", async () => {
// A proxy/gateway error page must not be parsed as JSON.
fetchMock.mockResolvedValue(
new Response("<html>Bad Gateway</html>", {
status: 422,
headers: { "Content-Type": "text/html" },
}),
);
const result = await generateCrossProviderPdf({
complianceId: "csa_ccm_4.0",
});
expect(result).toEqual({
error:
"Unable to start PDF generation. Contact support if the issue continues.",
});
});
});
describe("getCrossProviderPdfBinary", () => {
it("returns pending state on 202", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ data: { id: "task-1", attributes: { state: "executing" } } },
202,
),
);
const result = await getCrossProviderPdfBinary("task-1");
expect(result).toEqual({
pending: true,
state: "executing",
taskId: "task-1",
});
expect(lastFetchUrl().pathname).toBe(
"/api/v1/cross-provider-compliance-overviews/pdf/task-1",
);
});
it("returns the binary as base64 with the content-disposition filename", async () => {
fetchMock.mockResolvedValue(
new Response(Buffer.from("pdf-bytes"), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="csa-report.pdf"',
},
}),
);
const result = await getCrossProviderPdfBinary("task-1");
expect(result).toEqual({
success: true,
data: Buffer.from("pdf-bytes").toString("base64"),
filename: "csa-report.pdf",
});
});
it("rejects task ids that could smuggle path segments, without fetching", async () => {
const result = await getCrossProviderPdfBinary("../../../etc/passwd");
expect(result).toEqual({ error: "Invalid task identifier." });
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the API error detail on failure", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ errors: [{ detail: "Report expired." }] }, 410),
);
const result = await getCrossProviderPdfBinary("task-1");
expect(result).toEqual({ error: "Report expired." });
});
it("reports 5xx failures to Sentry", async () => {
fetchMock.mockResolvedValue(jsonResponse({}, 500));
const result = await getCrossProviderPdfBinary("task-1");
expect(result).toEqual({ error: "Generic server error." });
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]);
expect(serialized).toContain("pdf/{taskId}");
});
});
describe("getLatestCrossProviderPdf", () => {
it("returns the report descriptor when a matching report exists", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
data: {
type: "tasks",
id: "task-9",
attributes: {
completed_at: "2026-07-01T10:00:00Z",
result: { filename: "csa-latest.pdf" },
},
},
}),
);
const result = await getLatestCrossProviderPdf({
complianceId: "csa_ccm_4.0",
filters: { scanIds: ["scan-1"] },
});
expect(result).toEqual({
taskId: "task-9",
filename: "csa-latest.pdf",
completedAt: "2026-07-01T10:00:00Z",
});
const url = lastFetchUrl();
expect(url.pathname).toBe(
"/api/v1/cross-provider-compliance-overviews/pdf/latest",
);
expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1");
});
it("returns null when no report has been generated yet (404)", async () => {
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 404));
const result = await getLatestCrossProviderPdf({
complianceId: "csa_ccm_4.0",
});
expect(result).toBeNull();
});
it("degrades to null on 5xx but still reports to Sentry", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
fetchMock.mockResolvedValue(jsonResponse({}, 500));
const result = await getLatestCrossProviderPdf({
complianceId: "csa_ccm_4.0",
});
expect(result).toBeNull();
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
const serialized = JSON.stringify(captureExceptionMock.mock.calls[0]);
expect(serialized).toContain("pdf/latest");
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,315 @@
"use server";
import * as Sentry from "@sentry/nextjs";
import type { ScanBinaryResult } from "@/actions/scans/scans";
import {
apiBaseUrl,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import { hasActionError } from "@/lib/action-errors";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import type {
CrossProviderApiFilters,
CrossProviderOverviewResponse,
CrossProviderOverviewResult,
LatestCrossProviderPdf,
} from "../_types";
import {
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
} from "../_types";
const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews";
/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */
interface PdfEndpointErrorBody {
errors?: Array<{ detail?: string }>;
error?: string;
message?: string;
}
/**
* Extracts a user-safe message from a failed PDF endpoint response and, for
* unexpected failures, reports it to Sentry. `operation` must be a STATIC
* route template (e.g. `GET .../pdf/{taskId}`) — never the
* resolved URL, which would carry the task id or a user-typed report name.
*/
const getPdfEndpointErrorMessage = async (
response: Response,
fallbackMessage: string,
operation: string,
): Promise<string> => {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
const errorData: PdfEndpointErrorBody | null = contentType.includes(
"text/html",
)
? null
: await response.json().catch(() => null);
// These endpoints bypass handleApiResponse (binary/task protocol), so
// server failures would otherwise go unmonitored.
if (response.status >= 500) {
Sentry.captureException(
new Error(
`Cross-provider PDF request failed (${response.status}) at ${operation}`,
),
{
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.SERVER_ERROR,
error_source: SentryErrorSource.SERVER_ACTION,
},
level: "error",
contexts: {
api_response: {
status: response.status,
statusText: response.statusText,
operation,
},
},
},
);
return GENERIC_SERVER_ERROR_MESSAGE;
}
return (
errorData?.errors?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
fallbackMessage
);
};
/** Appends the shared cross-provider filter params to a request URL. */
const applyFilters = (url: URL, filters?: CrossProviderApiFilters) => {
if (!filters) return;
if (filters.scanIds && filters.scanIds.length > 0) {
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
}
const paramMap = {
"filter[provider_type__in]": filters.providerTypes,
"filter[provider_id__in]": filters.providerIds,
"filter[provider_groups__in]": filters.providerGroups,
};
for (const [key, value] of Object.entries(paramMap)) {
if (value && value.trim().length > 0) {
url.searchParams.set(key, value);
}
}
};
/**
* Aggregate a universal compliance framework across one scan per compatible
* provider (Prowler Cloud only — the OSS API has no such endpoint).
*
* When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED
* scan per compatible provider. Non-2xx responses are returned as structured
* action errors so callers can reuse the app-wide 402/403 handlers.
*/
export const getCrossProviderComplianceOverview = async ({
complianceId,
filters,
}: {
complianceId: string;
filters?: CrossProviderApiFilters;
}): Promise<CrossProviderOverviewResult> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}`);
url.searchParams.set("filter[compliance_id]", complianceId);
applyFilters(url, filters);
try {
const response = await fetch(url.toString(), { headers });
const responseData = await handleApiResponse(response);
if (hasActionError(responseData)) {
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
result: responseData,
};
}
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
response: responseData as CrossProviderOverviewResponse,
};
} catch (error) {
console.error("Error fetching cross-provider compliance overview:", error);
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
};
}
};
/**
* Trigger ad-hoc generation of the combined cross-provider compliance PDF.
*
* The PDF is built asynchronously by a backend task: this returns the task id
* so the caller can poll it and then download via
* {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on
* screen (`attributes.scan_ids`) so the report matches the displayed data
* instead of re-resolving "latest scan per provider", which could race a scan
* completing in between.
*/
export const generateCrossProviderPdf = async ({
complianceId,
filters,
reportName,
}: {
complianceId: string;
filters?: CrossProviderApiFilters;
/** Optional download filename; sanitized server-side. */
reportName?: string;
}): Promise<{ taskId: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf`);
url.searchParams.set("filter[compliance_id]", complianceId);
applyFilters(url, filters);
if (reportName) url.searchParams.set("report_name", reportName);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to start PDF generation. Contact support if the issue continues.",
`POST ${CROSS_PROVIDER_API_PATH}/pdf`,
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) {
throw new Error("Unexpected response starting PDF generation.");
}
return { taskId };
} catch (error) {
return { error: getErrorMessage(error) };
}
};
/**
* Fetch the finished cross-provider PDF for a task started by
* {@link generateCrossProviderPdf}. Speaks the same 202-pending /
* 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it
* returns the shared {@link ScanBinaryResult} shape and callers can reuse the
* existing download plumbing unchanged.
*/
export const getCrossProviderPdfBinary = async (
taskId: string,
): Promise<ScanBinaryResult> => {
// The task id reaches the URL path: constrain it to the task-id charset
// (UUIDs) so a crafted value cannot smuggle `/`, `..` or a host.
const safeTaskId = taskId.trim();
if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) {
return { error: "Invalid task identifier." };
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`,
);
try {
const response = await fetch(url.toString(), { headers });
if (response.status === 202) {
const json = await response.json();
return {
pending: true,
state: json?.data?.attributes?.state,
taskId: json?.data?.id,
};
}
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
`GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`,
),
);
}
const contentDisposition =
response.headers.get("content-disposition") || "";
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch?.[1] || "cross-provider-compliance.pdf";
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
return { success: true, data: base64, filename };
} catch (error) {
return { error: getErrorMessage(error) };
}
};
/**
* Check whether a cross-provider PDF already exists for the given filters so
* the UI can offer "Download" immediately instead of forcing a re-generate.
*
* 404 means "not generated yet" — a normal state, returned as `null` rather
* than an error. The backend only matches reports built from the exact scan
* set the filters resolve to, so a report goes stale (→ `null`) as soon as a
* contributing provider completes a new scan. Failures also degrade to
* `null`: this is an optional availability check and the caller's fallback
* (show "Generate") is always safe.
*/
export const getLatestCrossProviderPdf = async ({
complianceId,
filters,
}: {
complianceId: string;
filters?: CrossProviderApiFilters;
}): Promise<LatestCrossProviderPdf | null> => {
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;
}
};
@@ -0,0 +1,17 @@
import { COMPLIANCE_TAB, type ComplianceTab } from "../_types";
function isComplianceTab(value: string): value is ComplianceTab {
return Object.values(COMPLIANCE_TAB).includes(value as ComplianceTab);
}
/** Resolves `?tab=` into a valid tab, defaulting to Per Scan so existing
* bookmarks (no query param) keep working. */
function getComplianceTab(value: string | string[] | undefined): ComplianceTab {
if (typeof value !== "string") {
return COMPLIANCE_TAB.PER_SCAN;
}
return isComplianceTab(value) ? value : COMPLIANCE_TAB.PER_SCAN;
}
export { getComplianceTab };
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { COMPLIANCE_TAB } from "../_types";
import { CompliancePageTabs } from "./compliance-page-tabs";
import { getComplianceTab } from "./compliance-page-tabs.shared";
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
describe("getComplianceTab", () => {
it("falls back to per-scan for missing or invalid values", () => {
expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.PER_SCAN);
expect(getComplianceTab(["cross-provider"])).toBe(COMPLIANCE_TAB.PER_SCAN);
expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.PER_SCAN);
expect(getComplianceTab("cross-provider")).toBe(
COMPLIANCE_TAB.CROSS_PROVIDER,
);
});
});
describe("CompliancePageTabs", () => {
beforeEach(() => {
pushMock.mockClear();
});
it("navigates with ?tab=cross-provider and back to the bare route", async () => {
const user = userEvent.setup();
const { rerender } = render(
<CompliancePageTabs
activeTab={COMPLIANCE_TAB.PER_SCAN}
crossProviderEnabled
perScanContent={<div>Per scan content</div>}
crossProviderContent={<div>Cross provider content</div>}
/>,
);
await user.click(screen.getByRole("tab", { name: /cross-provider/i }));
expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider");
rerender(
<CompliancePageTabs
activeTab={COMPLIANCE_TAB.CROSS_PROVIDER}
crossProviderEnabled
perScanContent={<div>Per scan content</div>}
crossProviderContent={<div>Cross provider content</div>}
/>,
);
await user.click(screen.getByRole("tab", { name: /per scan/i }));
expect(pushMock).toHaveBeenCalledWith("/compliance");
});
it("disables the cross-provider tab with the cloud upsell badge in OSS", () => {
render(
<CompliancePageTabs
activeTab={COMPLIANCE_TAB.PER_SCAN}
crossProviderEnabled={false}
perScanContent={<div>Per scan content</div>}
crossProviderContent={null}
/>,
);
const crossProviderTab = screen.getByRole("tab", {
name: /cross-provider/i,
});
const tabLabel = screen.getByText("Cross-Provider", { exact: true });
const cloudBadge = screen.getByText("Available in Prowler Cloud");
expect(crossProviderTab).toBeDisabled();
expect(crossProviderTab).not.toHaveClass("disabled:opacity-50");
expect(tabLabel).toHaveClass("opacity-50");
expect(cloudBadge.parentElement).toHaveClass("gap-2");
});
});
@@ -0,0 +1,70 @@
"use client";
import { useRouter } from "next/navigation";
import { ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { CloudFeatureBadge } from "@/components/shared/cloud-feature-badge";
import { COMPLIANCE_TAB, type ComplianceTab } from "../_types";
interface CompliancePageTabsProps {
activeTab: ComplianceTab;
/** False in OSS: the Cross-Provider tab renders disabled with the
* "Available in Prowler Cloud" upsell badge. */
crossProviderEnabled: boolean;
perScanContent: ReactNode;
crossProviderContent: ReactNode;
}
export const CompliancePageTabs = ({
activeTab,
crossProviderEnabled,
perScanContent,
crossProviderContent,
}: CompliancePageTabsProps) => {
const router = useRouter();
const handleTabChange = (tab: string) => {
const typedTab = tab as ComplianceTab;
if (typedTab === activeTab) {
return;
}
// Per Scan renders without the query param so existing bookmarks and
// shared links keep resolving to the default view.
if (typedTab === COMPLIANCE_TAB.PER_SCAN) {
router.push("/compliance");
} else {
router.push(`/compliance?tab=${typedTab}`);
}
};
return (
// Same layout spacing as the scans view tabs (scans-page-shell.tsx).
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex flex-col gap-[18px]"
>
<TabsList className="overflow-x-auto">
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>Per Scan</TabsTrigger>
<TabsTrigger
value={COMPLIANCE_TAB.CROSS_PROVIDER}
disabled={!crossProviderEnabled}
adornment={!crossProviderEnabled ? <CloudFeatureBadge /> : undefined}
>
Cross-Provider
</TabsTrigger>
</TabsList>
<TabsContent value={COMPLIANCE_TAB.PER_SCAN}>
{perScanContent}
</TabsContent>
<TabsContent value={COMPLIANCE_TAB.CROSS_PROVIDER}>
{crossProviderContent}
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,241 @@
import { Info } from "lucide-react";
import Image from "next/image";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import {
ClientAccordionWrapper,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import type { Framework, RequirementsTotals } from "@/types/compliance";
import {
getCrossProviderComplianceOverview,
getLatestCrossProviderPdf,
} from "../_actions/cross-provider";
import { toCrossProviderAccordionItems } from "../_lib/cross-provider-accordion";
import {
buildRequirementExtrasMap,
computeProviderBreakdown,
crossProviderToMapperInput,
} from "../_lib/cross-provider-adapter";
import {
CROSS_PROVIDER_FRAMEWORKS,
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
CrossProviderGroupOption,
} from "./cross-provider-filters";
import { CrossProviderFilters } from "./cross-provider-filters";
import { CrossProviderHubLink } from "./cross-provider-hub-link";
import { CrossProviderPdfButton } from "./cross-provider-pdf-button";
import { ProviderCoverageCard } from "./provider-coverage-card";
interface CrossProviderDetailProps {
compliancetitle: string;
complianceId: string;
searchParams: Record<string, string | string[] | undefined>;
targetSection?: string;
}
/**
* Server island for the cross-provider detail (`?mode=cross-provider`):
* fetches the roll-up, funnels it through the real framework mapper via the
* adapter, and renders the same summary-charts + accordion layout as the
* per-scan detail with per-provider augmentations.
*/
export const CrossProviderDetail = async ({
compliancetitle,
complianceId,
searchParams,
targetSection,
}: CrossProviderDetailProps) => {
const filters = parseCrossProviderFilters(searchParams);
const [overviewResponse, providersData, providerGroupsData] =
await Promise.all([
getCrossProviderComplianceOverview({ complianceId, filters }),
getAllProviders(),
getAllProviderGroups(),
]);
if (
overviewResponse.status ===
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR
) {
return <CrossProviderErrorAlert result={overviewResponse.result} />;
}
if (
overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR
) {
return <CrossProviderErrorAlert message={overviewResponse.message} />;
}
const overviewData = overviewResponse.response.data;
if (!overviewData?.attributes) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No cross-provider compliance data was returned for this framework.
Universal frameworks aggregate the latest completed scan of every
compatible provider run a scan to populate this view.
</AlertDescription>
</Alert>
);
}
const attrs = overviewData.attributes;
// Scoped to the EXACT scans the overview resolved (not the raw filters), so
// an offered "Download latest" always matches the data on screen even if a
// provider finished a new scan between the two calls. The overview
// aggregation dominates wall-clock; serializing this quick check is cheap.
const latestPdf = await getLatestCrossProviderPdf({
complianceId,
filters: { ...filters, scanIds: attrs.scan_ids },
});
const mapper = getComplianceMapper(attrs.framework);
const { attributesData, requirementsData } =
crossProviderToMapperInput(attrs);
const data = mapper.mapComplianceData(attributesData, requirementsData);
const extras = buildRequirementExtrasMap(attrs);
const providerBreakdown = computeProviderBreakdown(attrs);
const totals: RequirementsTotals = data.reduce(
(acc: RequirementsTotals, framework: Framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const accordionItems = toCrossProviderAccordionItems(
data,
extras,
attrs.framework,
);
const topFailedResult = mapper.getTopFailedSections(data);
// Same `${framework.name}-${category.name}` key scheme as the per-scan
// detail, so ?section= deep links (e.g. from Top Failed Sections) work.
const initialExpandedKeys: string[] = [];
if (targetSection) {
const candidates = new Set(
data.map((framework: Framework) => `${framework.name}-${targetSection}`),
);
const match = accordionItems.find((item) => candidates.has(item.key));
if (match) {
initialExpandedKeys.push(match.key);
}
}
const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === complianceId,
);
const compatibleTypes =
catalogEntry?.compatibleProviders ??
providerBreakdown.map((b) => b.provider);
const logoPath = getComplianceIcon(compliancetitle);
const providerAccounts: CrossProviderAccountOption[] = (
providersData?.data || []
)
.filter((provider) =>
compatibleTypes.some((type) => type === provider.attributes.provider),
)
.map((provider) => ({
id: provider.id,
label: provider.attributes.alias
? `${provider.attributes.alias} (${provider.attributes.uid})`
: provider.attributes.uid,
type: provider.attributes.provider,
}));
const providerGroups: CrossProviderGroupOption[] = (
providerGroupsData?.data || []
).map((group) => ({ id: group.id, name: group.attributes.name }));
return (
<div className="flex flex-col gap-8">
{/* 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). */}
<Card variant="base" className="w-full gap-4 p-4 md:p-5">
<div className="flex w-full flex-col gap-4">
<div className="flex w-full items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
{logoPath && (
<div className="relative h-12 w-12 shrink-0">
<Image
src={logoPath}
alt={`${compliancetitle} logo`}
fill
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
/>
</div>
)}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{attrs.name || compliancetitle.split("-").join(" ")}
</span>
<CrossProviderHubLink complianceId={complianceId} />
</div>
<p className="text-text-neutral-tertiary text-xs">
{attrs.providers.length} of {compatibleTypes.length}{" "}
compatible providers scanned · {attrs.scan_ids.length}{" "}
{attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated
</p>
</div>
</div>
<div className="shrink-0">
<CrossProviderPdfButton
complianceId={complianceId}
filters={{ ...filters, scanIds: attrs.scan_ids }}
latestPdf={latestPdf}
/>
</div>
</div>
<CrossProviderFilters
providerTypes={compatibleTypes}
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
</div>
</Card>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-[minmax(280px,400px)_minmax(280px,360px)_1fr]">
<RequirementsStatusCard
pass={totals.pass}
fail={totals.fail}
manual={totals.manual}
/>
<ProviderCoverageCard breakdown={providerBreakdown} />
<TopFailedSectionsCard
sections={topFailedResult.items}
dataType={topFailedResult.type}
prepopulated={topFailedResult.prepopulated}
/>
</div>
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
);
};
@@ -0,0 +1,38 @@
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import {
ACTION_ERROR_STATUS,
type ActionErrorResult,
getActionErrorMessage,
} from "@/lib/action-errors";
import { CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE } from "../_types";
interface CrossProviderErrorAlertProps {
result?: ActionErrorResult;
message?: string;
}
export const CrossProviderErrorAlert = ({
result,
message = CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
}: CrossProviderErrorAlertProps) => {
const isUsageLimit = result?.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED;
return (
<Alert variant="error">
<AlertTriangle className="size-4" />
<AlertDescription>
{isUsageLimit ? (
<UsageLimitMessage />
) : result ? (
getActionErrorMessage(result, { fallback: message })
) : (
message
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { CrossProviderFilters } from "./cross-provider-filters";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: vi.fn() }),
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button>Clear filters</button>,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: ReactNode }) => <div>{children}</div>,
MultiSelectTrigger: ({
children,
...props
}: {
children: ReactNode;
"aria-label"?: string;
}) => (
<button role="combobox" {...props}>
{children}
</button>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
MultiSelectItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
MultiSelectSelectAll: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
MultiSelectSeparator: () => <hr />,
}));
describe("CrossProviderFilters", () => {
it("shows only the three cross-provider filters with product copy", () => {
// Given / When
render(
<CrossProviderFilters
providerTypes={["aws", "azure"]}
providerAccounts={[
{ id: "provider-1", label: "Production", type: "aws" },
]}
providerGroups={[{ id: "group-1", name: "Critical" }]}
/>,
);
// Then
expect(
screen.getByRole("combobox", { name: "Provider type" }),
).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "Providers" }),
).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "Provider group" }),
).toBeInTheDocument();
expect(screen.getAllByRole("combobox")).toHaveLength(3);
});
});
@@ -0,0 +1,119 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import {
type KnownProviderType,
PROVIDER_DISPLAY_NAMES,
type ProviderType,
} from "@/types/providers";
export interface CrossProviderAccountOption {
id: string;
label: string;
type: ProviderType;
}
export interface CrossProviderGroupOption {
id: string;
name: string;
}
interface CrossProviderFiltersProps {
/** Provider types offered by the visible universal frameworks. */
providerTypes: readonly KnownProviderType[];
providerAccounts: CrossProviderAccountOption[];
providerGroups: CrossProviderGroupOption[];
}
interface UrlMultiSelectOption {
value: string;
label: string;
}
interface UrlMultiSelectProps {
filterKey: string;
placeholder: string;
options: UrlMultiSelectOption[];
}
const UrlMultiSelect = ({
filterKey,
placeholder,
options,
}: UrlMultiSelectProps) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const values =
searchParams.get(`filter[${filterKey}]`)?.split(",").filter(Boolean) ?? [];
return (
<div className="w-full sm:max-w-[280px] sm:min-w-[180px] sm:flex-1">
<MultiSelect
values={values}
onValuesChange={(nextValues) => updateFilter(filterKey, nextValues)}
>
<MultiSelectTrigger size="default" aria-label={placeholder}>
<MultiSelectValue placeholder={placeholder} />
</MultiSelectTrigger>
<MultiSelectContent search={options.length > 8} width="wide">
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{options.map((option) => (
<MultiSelectItem key={option.value} value={option.value}>
{option.label}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
</div>
);
};
export const CrossProviderFilters = ({
providerTypes,
providerAccounts,
providerGroups,
}: CrossProviderFiltersProps) => {
return (
<div className="flex flex-wrap items-center gap-4">
<UrlMultiSelect
filterKey="provider_type__in"
placeholder="Provider type"
options={providerTypes.map((type) => ({
value: type,
label: PROVIDER_DISPLAY_NAMES[type],
}))}
/>
<UrlMultiSelect
filterKey="provider_id__in"
placeholder="Providers"
options={providerAccounts.map((account) => ({
value: account.id,
label: account.label,
}))}
/>
<UrlMultiSelect
filterKey="provider_groups__in"
placeholder="Provider group"
options={providerGroups.map((group) => ({
value: group.id,
label: group.name,
}))}
/>
<ClearFiltersButton showCount />
</div>
);
};
@@ -0,0 +1,162 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { Progress } from "@/components/shadcn/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
getScoreIndicatorClass,
type ScoreColorVariant,
} from "@/lib/compliance/score-utils";
import { cn } from "@/lib/utils";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { buildCrossProviderDetailHref } from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
export const CrossProviderFrameworkCard = ({
complianceId,
title,
version,
description,
requirementsPassed,
requirementsFailed,
requirementsManual,
totalRequirements,
providerBreakdown,
}: CrossProviderFrameworkSummary) => {
const router = useRouter();
const searchParams = useSearchParams();
const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
const ratingPercentage =
totalRequirements > 0
? Math.floor((requirementsPassed / totalRequirements) * 100)
: 0;
// Same thresholds as the per-scan ComplianceCard.
const getRatingVariant = (value: number): ScoreColorVariant => {
if (value <= 10) return "danger";
if (value <= 40) return "warning";
return "success";
};
const navigateToDetail = () => {
router.push(
buildCrossProviderDetailHref(
{ complianceId, title, version },
Object.fromEntries(searchParams.entries()),
),
);
};
return (
<Card
variant="base"
padding="md"
className="relative cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
role="button"
aria-label={formattedTitle}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigateToDetail();
}
}}
>
<CardContent className="p-0">
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-3">
{getComplianceIcon(title) && (
<div className="flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
width={32}
height={32}
className="h-8 w-8 object-contain"
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="truncate text-sm leading-5 font-bold">
{formattedTitle}
</h4>
</TooltipTrigger>
<TooltipContent>{description}</TooltipContent>
</Tooltip>
<small className="truncate">
<span className="mr-1 text-xs font-semibold">
{requirementsPassed} / {totalRequirements}
</span>
Passing Requirements
</small>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-text-neutral-secondary font-medium tracking-wider">
Score:
</span>
<span className="text-text-neutral-secondary">
{ratingPercentage}%
</span>
</div>
<Progress
aria-label="Cross-provider compliance score"
value={ratingPercentage}
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
indicatorClassName={getScoreIndicatorClass(
getRatingVariant(ratingPercentage),
)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-1.5">
{providerBreakdown.map((entry) => (
<Tooltip key={entry.provider}>
<TooltipTrigger asChild>
<span
data-testid={`provider-chip-${entry.provider}`}
data-unscanned={entry.unscanned || undefined}
className={cn(
"inline-flex items-center",
entry.unscanned && "opacity-35 grayscale",
)}
>
<ProviderTypeIcon type={entry.provider} size={18} />
</span>
</TooltipTrigger>
<TooltipContent>
{PROVIDER_DISPLAY_NAMES[entry.provider]}
{entry.unscanned
? " — no completed scan yet"
: `${entry.score}% passing`}
</TooltipContent>
</Tooltip>
))}
</div>
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
{requirementsFailed} failed · {requirementsManual} manual
</span>
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CrossProviderHubLink } from "./cross-provider-hub-link";
describe("CrossProviderHubLink", () => {
it("opens the framework page in Prowler Hub safely", () => {
// Given / When
render(<CrossProviderHubLink complianceId="cis_controls_8.1" />);
// Then
const link = screen.getByRole("link", { name: /view on prowler hub/i });
expect(link).toHaveAttribute(
"href",
"https://hub.prowler.com/compliance/cis_controls_8.1",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
@@ -0,0 +1,24 @@
import { SquareArrowOutUpRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
interface CrossProviderHubLinkProps {
complianceId: string;
}
export const CrossProviderHubLink = ({
complianceId,
}: CrossProviderHubLinkProps) => (
<Button variant="link" size="link-xs" asChild>
<Link
href={`https://hub.prowler.com/compliance/${encodeURIComponent(complianceId)}`}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
View on Prowler Hub
<SquareArrowOutUpRight />
</Link>
</Button>
);
@@ -0,0 +1,137 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ACTION_ERROR_STATUS, USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { getCrossProviderComplianceOverview } from "../_actions/cross-provider";
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
import type { CrossProviderOverviewResult } from "../_types";
import {
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
CROSS_PROVIDER_OVERVIEW_TYPE,
} from "../_types";
import { CrossProviderOverview } from "./cross-provider-overview";
vi.mock("../_actions/cross-provider", () => ({
getCrossProviderComplianceOverview: vi.fn(),
}));
vi.mock("@/actions/providers", () => ({
getAllProviders: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/actions/manage-groups/manage-groups", () => ({
getAllProviderGroups: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("./cross-provider-filters", () => ({
CrossProviderFilters: () => <div data-testid="cross-provider-filters" />,
}));
vi.mock("./cross-provider-framework-card", () => ({
CrossProviderFrameworkCard: ({ title }: { title: string }) => (
<div data-testid="framework-card">{title}</div>
),
}));
const successResult = (complianceId: string): CrossProviderOverviewResult => ({
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
response: {
data: {
type: CROSS_PROVIDER_OVERVIEW_TYPE,
id: complianceId,
attributes: {
compliance_id: complianceId,
framework: complianceId,
name: complianceId,
version: "1.0",
description: "",
compatible_providers: ["aws"],
requested_providers: ["aws"],
providers: ["aws"],
scan_ids: [],
scan_ids_by_provider: {},
requirements_passed: 1,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: 1,
requirements: [],
},
},
},
});
const loadErrorResult: CrossProviderOverviewResult = {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
};
const renderOverview = async () =>
render(await CrossProviderOverview({ searchParams: {} }));
describe("CrossProviderOverview", () => {
beforeEach(() => {
vi.mocked(getCrossProviderComplianceOverview).mockReset();
});
it("degrades to a partial view when a single framework fails to load", async () => {
// Given: DORA fails, the other frameworks load
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) =>
complianceId === "dora_2022_2554"
? loadErrorResult
: successResult(complianceId),
);
// When
await renderOverview();
// Then: loaded cards render, the failed framework is called out by name
expect(screen.getAllByTestId("framework-card")).toHaveLength(
CROSS_PROVIDER_FRAMEWORKS.length - 1,
);
expect(screen.getByText(/Could not load DORA/)).toBeInTheDocument();
expect(
screen.queryByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE),
).not.toBeInTheDocument();
});
it("replaces the tab with the error alert when every framework fails to load", async () => {
// Given
vi.mocked(getCrossProviderComplianceOverview).mockResolvedValue(
loadErrorResult,
);
// When
await renderOverview();
// Then
expect(
screen.getByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE),
).toBeInTheDocument();
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
});
it("gates the whole tab on an action error even if other frameworks loaded", async () => {
// Given: one framework hits the usage limit (402)
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) =>
complianceId === "dora_2022_2554"
? {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
result: { status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED },
}
: successResult(complianceId),
);
// When
await renderOverview();
// Then
expect(
screen.getByText(new RegExp(USAGE_LIMIT_MESSAGE)),
).toBeInTheDocument();
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,189 @@
import { AlertTriangle, Info } from "lucide-react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { SearchParamsProps } from "@/types";
import type { KnownProviderType } from "@/types/providers";
import { getCrossProviderComplianceOverview } from "../_actions/cross-provider";
import { computeProviderBreakdown } from "../_lib/cross-provider-adapter";
import {
CROSS_PROVIDER_FRAMEWORKS,
type CrossProviderFrameworkEntry,
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
CrossProviderGroupOption,
} from "./cross-provider-filters";
import { CrossProviderFilters } from "./cross-provider-filters";
import { CrossProviderFrameworkCard } from "./cross-provider-framework-card";
/** Zero-state summary: the framework renders with every compatible provider
* chip dimmed when the API returned nothing usable (e.g. no scans yet). */
const emptySummary = (
entry: CrossProviderFrameworkEntry,
): CrossProviderFrameworkSummary => ({
complianceId: entry.complianceId,
title: entry.title,
version: entry.version,
description: entry.description,
requirementsPassed: 0,
requirementsFailed: 0,
requirementsManual: 0,
totalRequirements: 0,
providerBreakdown: entry.compatibleProviders.map((provider) => ({
provider,
pass: 0,
fail: 0,
manual: 0,
total: 0,
score: 0,
unscanned: true,
})),
});
/**
* Server island for the Cross-Provider tab: fetches the roll-up for every
* catalog framework in parallel and renders the filter row plus the cards
* grid. Rendered only in Prowler Cloud with the tab active, so OSS and the
* Per Scan tab never pay for these aggregation calls.
*/
export const CrossProviderOverview = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const filters = parseCrossProviderFilters(searchParams);
const [responses, providersData, providerGroupsData] = await Promise.all([
Promise.all(
CROSS_PROVIDER_FRAMEWORKS.map((entry) =>
getCrossProviderComplianceOverview({
complianceId: entry.complianceId,
filters,
}).then((result) => ({ entry, result })),
),
),
getAllProviders(),
getAllProviderGroups(),
]);
// Action errors (402 usage limit, 403) gate the whole feature, not one
// framework, so any of them replaces the tab instead of degrading it.
const actionError = responses.find(
({ result }) =>
result.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
);
if (
actionError?.result.status ===
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR
) {
return <CrossProviderErrorAlert result={actionError.result.result} />;
}
// Load errors are per-framework and often transient: degrade to a partial
// view with a warning, and only replace the tab when nothing loaded.
const loadErrors = responses.flatMap(({ entry, result }) =>
result.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR
? [{ entry, result }]
: [],
);
if (loadErrors.length === responses.length && loadErrors.length > 0) {
return <CrossProviderErrorAlert message={loadErrors[0].result.message} />;
}
const summaries: CrossProviderFrameworkSummary[] = responses
.filter(
({ result }) =>
result.status !== CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
)
.map(({ entry, result }) => {
if (result.status !== CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS) {
return emptySummary(entry);
}
const data = result.response.data;
if (!data?.attributes) return emptySummary(entry);
const attrs = data.attributes;
return {
complianceId: entry.complianceId,
title: entry.title,
version: entry.version,
description: entry.description,
requirementsPassed: attrs.requirements_passed,
requirementsFailed: attrs.requirements_failed,
requirementsManual: attrs.requirements_manual,
totalRequirements: attrs.total_requirements,
providerBreakdown: computeProviderBreakdown(attrs),
};
});
const compatibleTypes = Array.from(
new Set<KnownProviderType>(
CROSS_PROVIDER_FRAMEWORKS.flatMap((entry) => entry.compatibleProviders),
),
).sort();
const providerAccounts: CrossProviderAccountOption[] = (
providersData?.data || []
)
.filter((provider) =>
compatibleTypes.some((type) => type === provider.attributes.provider),
)
.map((provider) => ({
id: provider.id,
label: provider.attributes.alias
? `${provider.attributes.alias} (${provider.attributes.uid})`
: provider.attributes.uid,
type: provider.attributes.provider,
}));
const providerGroups: CrossProviderGroupOption[] = (
providerGroupsData?.data || []
).map((group) => ({ id: group.id, name: group.attributes.name }));
return (
<div className="flex flex-col gap-6">
<CrossProviderFilters
providerTypes={compatibleTypes}
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
{loadErrors.length > 0 && (
<Alert variant="warning">
<AlertTriangle className="size-4" />
<AlertDescription>
Could not load{" "}
{loadErrors.map(({ entry }) => entry.title).join(", ")}. Showing the
frameworks that loaded try again later.
</AlertDescription>
</Alert>
)}
{loadErrors.length === 0 &&
summaries.every((summary) => summary.totalRequirements === 0) && (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No cross-provider compliance data yet. Universal frameworks
aggregate the latest completed scan of every compatible provider
run a scan to populate these cards.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{summaries.map((summary) => (
<CrossProviderFrameworkCard key={summary.complianceId} {...summary} />
))}
</div>
</div>
);
};
@@ -0,0 +1,233 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CrossProviderPdfButton } from "./cross-provider-pdf-button";
// Radix dialogs/dropdowns rely on pointer-capture and scrollIntoView, which
// jsdom does not implement.
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
const {
generatePdfMock,
trackAndPollMock,
downloadPdfMock,
toastMock,
storeState,
} = vi.hoisted(() => ({
generatePdfMock: vi.fn(),
trackAndPollMock: vi.fn(),
downloadPdfMock: vi.fn(),
toastMock: vi.fn(),
storeState: {
tasks: {} as Record<
string,
{
taskId: string;
kind: string;
status: string;
meta: Record<string, string>;
startedAt: number;
}
>,
},
}));
vi.mock("../_actions/cross-provider", () => ({
generateCrossProviderPdf: generatePdfMock,
}));
vi.mock("../_lib/cross-provider-pdf", () => ({
CROSS_PROVIDER_PDF_TASK_KIND: "cross-provider-pdf",
buildCrossProviderPdfTaskScope: vi.fn(() => "scope-1"),
downloadCrossProviderPdf: downloadPdfMock,
crossProviderPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
}));
vi.mock("@/store/task-watcher/store", () => ({
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
trackAndPollTask: trackAndPollMock,
useTaskWatcherStore: (selector: (state: typeof storeState) => unknown) =>
selector(storeState),
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
ToastAction: () => null,
}));
const props = {
complianceId: "csa_ccm_4.0",
filters: { scanIds: ["scan-1"], providerTypes: "aws" },
latestPdf: null,
};
describe("CrossProviderPdfButton", () => {
beforeEach(() => {
vi.clearAllMocks();
storeState.tasks = {};
generatePdfMock.mockResolvedValue({ taskId: "task-1" });
});
const openGenerateModal = async (
user: ReturnType<typeof userEvent.setup>,
) => {
await user.click(screen.getByRole("button", { name: /report/i }));
await user.click(
await screen.findByRole("menuitem", { name: /generate new report/i }),
);
};
it("generates a named report without unsupported requirement options", async () => {
// Given
const user = userEvent.setup();
render(<CrossProviderPdfButton {...props} />);
// When
await openGenerateModal(user);
await user.type(
screen.getByLabelText(/report name/i),
"quarterly-audit.pdf",
);
expect(screen.queryByLabelText(/only failed/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/include manual/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^generate$/i }));
// Then
await waitFor(() => expect(generatePdfMock).toHaveBeenCalledTimes(1));
expect(generatePdfMock).toHaveBeenCalledWith({
complianceId: "csa_ccm_4.0",
filters: props.filters,
reportName: "quarterly-audit.pdf",
});
expect(trackAndPollMock).toHaveBeenCalledWith({
taskId: "task-1",
kind: "cross-provider-pdf",
meta: expect.objectContaining({ complianceId: "csa_ccm_4.0" }),
});
});
it("surfaces generation errors as a destructive toast without tracking", async () => {
generatePdfMock.mockResolvedValue({ error: "No compatible scans." });
const user = userEvent.setup();
render(<CrossProviderPdfButton {...props} />);
await openGenerateModal(user);
await user.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ variant: "destructive" }),
),
);
expect(trackAndPollMock).not.toHaveBeenCalled();
});
it("shows a generating state while a task for this framework is pending", () => {
storeState.tasks = {
"task-9": {
taskId: "task-9",
kind: "cross-provider-pdf",
status: "pending",
meta: { complianceId: "csa_ccm_4.0", scopeKey: "scope-1" },
startedAt: Date.now(),
},
};
render(<CrossProviderPdfButton {...props} />);
expect(screen.getByRole("button", { name: /generating/i })).toBeDisabled();
});
it("offers an instant download when a matching report already exists", async () => {
const user = userEvent.setup();
render(
<CrossProviderPdfButton
{...props}
latestPdf={{
taskId: "task-7",
filename: "csa-latest.pdf",
completedAt: "2026-07-01T10:00:00Z",
}}
/>,
);
await user.click(screen.getByRole("button", { name: /report/i }));
await user.click(
await screen.findByRole("menuitem", { name: /download latest/i }),
);
await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-7"));
});
it("keeps a completed report downloadable after the ready toast closes", async () => {
// Given
storeState.tasks = {
"task-8": {
taskId: "task-8",
kind: "cross-provider-pdf",
status: "ready",
meta: {
complianceId: "csa_ccm_4.0",
scopeKey: "scope-1",
reportLabel: "quarterly-audit.pdf",
},
startedAt: Date.now(),
},
};
const user = userEvent.setup();
render(<CrossProviderPdfButton {...props} />);
// When
await user.click(screen.getByRole("button", { name: /report/i }));
await user.click(
await screen.findByRole("menuitem", { name: /download latest/i }),
);
// Then
await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-8"));
});
it("does not offer a completed report from a different filter scope", async () => {
// Given
storeState.tasks = {
"task-other-scope": {
taskId: "task-other-scope",
kind: "cross-provider-pdf",
status: "ready",
meta: {
complianceId: "csa_ccm_4.0",
scopeKey: "another-scope",
},
startedAt: Date.now(),
},
};
const user = userEvent.setup();
render(<CrossProviderPdfButton {...props} />);
// When
await user.click(screen.getByRole("button", { name: /report/i }));
// Then
expect(
screen.queryByRole("menuitem", { name: /download latest/i }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,208 @@
"use client";
import { ChevronDownIcon, DownloadIcon, FileTextIcon } from "lucide-react";
import { useState } from "react";
import { Button, Label } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { FormButtons } from "@/components/shadcn/form";
import { Input } from "@/components/shadcn/input/input";
import { Modal } from "@/components/shadcn/modal";
import { toast } from "@/components/shadcn/toast";
import {
TASK_WATCHER_STATUS,
trackAndPollTask,
useTaskWatcherStore,
} from "@/store/task-watcher/store";
import { generateCrossProviderPdf } from "../_actions/cross-provider";
import {
buildCrossProviderPdfTaskScope,
CROSS_PROVIDER_PDF_TASK_KIND,
downloadCrossProviderPdf,
} from "../_lib/cross-provider-pdf";
import type {
CrossProviderApiFilters,
LatestCrossProviderPdf,
} from "../_types";
interface CrossProviderPdfButtonProps {
complianceId: string;
/** The filters (and exact scan ids) of the view currently on screen, so
* the generated PDF matches what the user is looking at. */
filters: CrossProviderApiFilters;
/** Already-generated report matching these filters, if any — offered as an
* instant download instead of forcing a re-generate. */
latestPdf: LatestCrossProviderPdf | null;
}
export const CrossProviderPdfButton = ({
complianceId,
filters,
latestPdf,
}: CrossProviderPdfButtonProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [reportName, setReportName] = useState("");
const [submitting, setSubmitting] = useState(false);
const taskScope = buildCrossProviderPdfTaskScope(complianceId, filters);
const isGenerating = useTaskWatcherStore((state) =>
Object.values(state.tasks).some(
(task) =>
task.kind === CROSS_PROVIDER_PDF_TASK_KIND &&
task.status === TASK_WATCHER_STATUS.PENDING &&
task.meta.scopeKey === taskScope,
),
);
const completedTask = useTaskWatcherStore((state) =>
Object.values(state.tasks).reduce<(typeof state.tasks)[string] | undefined>(
(latest, task) => {
if (
task.kind !== CROSS_PROVIDER_PDF_TASK_KIND ||
task.status !== TASK_WATCHER_STATUS.READY ||
task.meta.scopeKey !== taskScope
) {
return latest;
}
return !latest || task.startedAt > latest.startedAt ? task : latest;
},
undefined,
),
);
const availablePdf: LatestCrossProviderPdf | null = completedTask
? {
taskId: completedTask.taskId,
filename: completedTask.meta.reportLabel,
}
: latestPdf;
const handleGenerate = async () => {
setSubmitting(true);
try {
const result = await generateCrossProviderPdf({
complianceId,
filters,
reportName: reportName.trim() || undefined,
});
if ("error" in result) {
toast({
variant: "destructive",
title: "Could not start report generation",
description: result.error,
});
return;
}
setDialogOpen(false);
toast({
title: "Report generation started",
description:
"We'll let you know when the PDF is ready — you can keep working meanwhile.",
});
await trackAndPollTask({
taskId: result.taskId,
kind: CROSS_PROVIDER_PDF_TASK_KIND,
meta: {
complianceId,
scopeKey: taskScope,
...(reportName.trim() ? { reportLabel: reportName.trim() } : {}),
},
});
} catch {
// The action returns {error} for API failures; this guards the
// server-action RPC itself (e.g. a network drop mid-request).
toast({
variant: "destructive",
title: "Could not start report generation",
description: "An unexpected error occurred. Please try again later.",
});
} finally {
setSubmitting(false);
}
};
const formatGeneratedAt = (completedAt?: string) => {
if (!completedAt) return "";
const date = new Date(completedAt);
return Number.isNaN(date.getTime())
? ""
: ` (${date.toLocaleDateString()})`;
};
return (
<>
{/* Same trigger as the per-scan detail's export dropdown, so both
compliance headers expose one consistent "Report" action. */}
{isGenerating ? (
<Button variant="outline" disabled>
Generating report
</Button>
) : (
<ActionDropdown
variant="bordered"
ariaLabel="Compliance report actions"
trigger={
<Button variant="outline">
Report
<ChevronDownIcon />
</Button>
}
>
{availablePdf && (
<ActionDropdownItem
icon={<DownloadIcon />}
label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`}
description={availablePdf.filename}
onSelect={() => downloadCrossProviderPdf(availablePdf.taskId)}
/>
)}
<ActionDropdownItem
icon={<FileTextIcon />}
label="Generate new report…"
onSelect={() => setDialogOpen(true)}
/>
</ActionDropdown>
)}
<Modal
open={dialogOpen}
onOpenChange={setDialogOpen}
title="Generate Cross-Provider Report"
description="The report covers the providers, accounts and filters currently applied to this view."
size="xl"
>
<form
onSubmit={(event) => {
event.preventDefault();
handleGenerate();
}}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="cross-provider-report-name">Report name</Label>
<Input
id="cross-provider-report-name"
placeholder="Optional — a timestamped name is used by default"
value={reportName}
onChange={(event) => setReportName(event.target.value)}
/>
</div>
</div>
<FormButtons
onCancel={() => setDialogOpen(false)}
submitText="Generate"
loadingText="Starting..."
isDisabled={submitting}
/>
</form>
</Modal>
</>
);
};
@@ -0,0 +1,120 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { CrossProviderRequirementExtras } from "../_types";
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
const { clientAccordionContentMock } = vi.hoisted(() => ({
clientAccordionContentMock: vi.fn(
({
requirement,
scanIds,
}: {
requirement: Requirement;
scanIds: string[];
framework: string;
checkProviders: CheckProviderTypesMap;
disableFindings?: boolean;
}) => (
<div data-testid="findings-content">
{scanIds.join("|")}:{requirement.check_ids.join("|")}:
{requirement.status}
</div>
),
),
}));
// The real ClientAccordionContent drags the findings/server-action chain into
// jsdom; its behavior has its own tests. Here we assert the combined-view
// composition around it.
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: clientAccordionContentMock }),
);
const mappedRequirement: Requirement = {
name: "A&A-01 - Audit and Assurance Policy and Procedures",
description: "Establish audit policies.",
status: "FAIL",
pass: 0,
fail: 1,
manual: 0,
check_ids: ["aws_check", "azure_check", "shared_check"],
scope_applicability: "IaaS",
};
const extras: CrossProviderRequirementExtras = {
requirementId: "A&A-01",
providers: { aws: "FAIL", azure: "PASS" },
checkIdsByProvider: {
aws: ["aws_check", "shared_check"],
azure: ["azure_check", "shared_check"],
},
scanIdsByProvider: {
aws: ["scan-aws-1", "scan-aws-2"],
azure: ["scan-azure-1"],
},
};
describe("CrossProviderRequirementContent", () => {
it("renders the requirement once with all contributing scans combined", () => {
clientAccordionContentMock.mockClear();
render(
<CrossProviderRequirementContent
requirement={mappedRequirement}
extras={extras}
framework="CSA-CCM"
/>,
);
// One combined block — no per-provider sections repeating the detail.
expect(clientAccordionContentMock).toHaveBeenCalledTimes(1);
expect(screen.queryByText(/account 1 of/)).not.toBeInTheDocument();
expect(screen.getByTestId("findings-content")).toHaveTextContent(
"scan-aws-1|scan-aws-2|scan-azure-1:aws_check|azure_check|shared_check:FAIL",
);
// The mapped requirement passes through untouched (union checks,
// roll-up status, detail fields for getDetailsComponent).
const call = clientAccordionContentMock.mock.calls.at(-1)?.[0];
expect(call?.requirement).toBe(mappedRequirement);
expect(call?.framework).toBe("CSA-CCM");
expect(call?.disableFindings).toBe(false);
});
it("disables findings when no provider contributed any check", () => {
clientAccordionContentMock.mockClear();
render(
<CrossProviderRequirementContent
requirement={{ ...mappedRequirement, check_ids: [], status: "MANUAL" }}
extras={{
...extras,
providers: { aws: "MANUAL", azure: "MANUAL" },
checkIdsByProvider: {},
}}
framework="CSA-CCM"
/>,
);
const call = clientAccordionContentMock.mock.calls.at(-1)?.[0];
expect(call?.disableFindings).toBe(true);
});
it("shows an empty state when no provider scan contributed", () => {
clientAccordionContentMock.mockClear();
render(
<CrossProviderRequirementContent
requirement={mappedRequirement}
extras={{ ...extras, providers: {} }}
framework="CSA-CCM"
/>,
);
expect(
screen.getByText(/No provider scan contributed/),
).toBeInTheDocument();
expect(clientAccordionContentMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
"use client";
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import type { Requirement } from "@/types/compliance";
import { PROVIDER_TYPES } from "@/types/providers";
import { invertCheckIdsByProvider } from "../_lib/cross-provider-adapter";
import type { CrossProviderRequirementExtras } from "../_types";
interface CrossProviderRequirementContentProps {
/** The requirement as produced by the framework mapper (roll-up level). */
requirement: Requirement;
extras: CrossProviderRequirementExtras;
framework: string;
}
/**
* Combined findings view for a cross-provider requirement: the requirement
* detail rendered once, one checks list labeling each check with its provider
* types, and a single findings table querying every contributing scan at once
* (each row carries its own provider column). Mounts lazily — the requirement
* accordion unmounts collapsed content, so the combined fetch only fires on
* expand.
*/
export const CrossProviderRequirementContent = ({
requirement,
extras,
framework,
}: CrossProviderRequirementContentProps) => {
const contributingTypes = PROVIDER_TYPES.filter(
(type) => extras.providers[type],
);
if (contributingTypes.length === 0) {
return (
<p className="text-sm">
No provider scan contributed to this requirement with the current
filters.
</p>
);
}
const scanIds = Array.from(
new Set(
contributingTypes.flatMap((type) => extras.scanIdsByProvider[type] ?? []),
),
);
return (
<ClientAccordionContent
requirement={requirement}
scanIds={scanIds}
framework={framework}
checkProviders={invertCheckIdsByProvider(extras.checkIdsByProvider)}
disableFindings={requirement.check_ids.length === 0}
/>
);
};
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderBreakdownEntry } from "../_types";
import { ProviderCoverageCard } from "./provider-coverage-card";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
ProviderTypeIcon: () => <span aria-hidden="true" />,
}));
const scannedProvider: ProviderBreakdownEntry = {
provider: "aws",
pass: 8,
fail: 2,
manual: 1,
total: 11,
score: 80,
unscanned: false,
};
const unscannedProvider: ProviderBreakdownEntry = {
provider: "gcp",
pass: 0,
fail: 0,
manual: 0,
total: 0,
score: 0,
unscanned: true,
};
describe("ProviderCoverageCard", () => {
it("shows only providers that have a scan", () => {
// Given / When
render(
<ProviderCoverageCard breakdown={[scannedProvider, unscannedProvider]} />,
);
// Then
expect(screen.getByTestId("coverage-row-aws")).toBeInTheDocument();
expect(screen.queryByTestId("coverage-row-gcp")).not.toBeInTheDocument();
expect(screen.queryByText("No completed scan")).not.toBeInTheDocument();
});
it("shows an empty state when no provider has a scan", () => {
// Given / When
render(<ProviderCoverageCard breakdown={[unscannedProvider]} />);
// Then
expect(
screen.getByText("No scanned providers for this framework yet."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
"use client";
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 { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import type { ProviderBreakdownEntry } from "../_types";
interface ProviderCoverageCardProps {
breakdown: ProviderBreakdownEntry[];
}
/** Per-provider pass score for the cross-provider detail: one row per
* provider with a completed scan. */
export const ProviderCoverageCard = ({
breakdown,
}: ProviderCoverageCardProps) => {
const scannedProviders = breakdown.filter((entry) => !entry.unscanned);
return (
<Card variant="base" className="flex h-full min-h-[372px] flex-col">
<CardHeader>
<CardTitle>Provider Coverage</CardTitle>
</CardHeader>
{/* Capped + scrollable so a long provider list never stretches the
sibling chart cards in the same grid row. */}
<CardContent className="minimal-scrollbar flex max-h-[300px] flex-col gap-4 overflow-y-auto">
{scannedProviders.length === 0 && (
<p className="text-text-neutral-secondary text-sm">
No scanned providers for this framework yet.
</p>
)}
{scannedProviders.map((entry) => (
<div
key={entry.provider}
data-testid={`coverage-row-${entry.provider}`}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIcon type={entry.provider} size={18} />
<span className="truncate">
{PROVIDER_DISPLAY_NAMES[entry.provider]}
</span>
</span>
<span className="text-text-neutral-secondary text-xs">
{entry.score}%
</span>
</div>
<div className="mt-1.5 flex items-center gap-3">
<Progress
aria-label={`${PROVIDER_DISPLAY_NAMES[entry.provider]} passing score`}
value={entry.score}
className="border-border-neutral-secondary h-2 border"
indicatorClassName={getScoreIndicatorClass(
getScoreColor(entry.score),
)}
/>
<span className="text-text-neutral-tertiary text-xs whitespace-nowrap">
{entry.pass}/{entry.pass + entry.fail} · {entry.manual} manual
</span>
</div>
</div>
))}
</CardContent>
</Card>
);
};
@@ -0,0 +1,50 @@
"use client";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { PROVIDER_DISPLAY_NAMES, PROVIDER_TYPES } from "@/types/providers";
import type { ProviderStatusMap } from "../_types";
interface RequirementProviderChipsProps {
providers: ProviderStatusMap;
}
/** Per-provider status chips shown next to a cross-provider requirement:
* each contributing provider's icon paired with its own PASS/FAIL/MANUAL. */
export const RequirementProviderChips = ({
providers,
}: RequirementProviderChipsProps) => {
// Iterate the canonical order so chips are stable across requirements.
const entries = PROVIDER_TYPES.filter((type) => providers[type]);
return (
<div className="flex flex-wrap items-center gap-2">
{entries.map((type) => (
<Tooltip key={type}>
<TooltipTrigger asChild>
<span
data-testid={`requirement-chip-${type}`}
className="inline-flex items-center gap-1"
>
<ProviderTypeIcon type={type} size={16} />
<StatusFindingBadge
status={providers[type] as FindingStatus}
size="sm"
/>
</span>
</TooltipTrigger>
<TooltipContent>{PROVIDER_DISPLAY_NAMES[type]}</TooltipContent>
</Tooltip>
))}
</div>
);
};
@@ -0,0 +1,109 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Framework } from "@/types/compliance";
// The requirement content component drags the findings/server-action chain
// into jsdom; assembly structure is what's under test here.
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: () => null }),
);
// The section header reaches next-auth through the shadcn/table barrel
// (data-table-pagination → @/lib). Same stub as compliance-mapper.test.ts.
// The requirement title is rendered for real here — it only pulls the
// leak-free status-finding-badge file + provider icons.
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-title",
() => ({ ComplianceAccordionTitle: () => null }),
);
import type { CrossProviderRequirementExtras } from "../../_types";
import { toCrossProviderAccordionItems } from "../cross-provider-accordion";
const data: Framework[] = [
{
name: "CSA-CCM",
pass: 1,
fail: 1,
manual: 0,
categories: [
{
name: "Audit & Assurance",
pass: 1,
fail: 1,
manual: 0,
controls: [
{
label: "Audit & Assurance",
pass: 1,
fail: 1,
manual: 0,
requirements: [
{
name: "A&A-01 - Audit Policy",
description: "desc",
status: "FAIL",
pass: 0,
fail: 1,
manual: 0,
check_ids: ["check_a"],
},
{
name: "A&A-02 - Independent Assessments",
description: "desc",
status: "PASS",
pass: 1,
fail: 0,
manual: 0,
check_ids: [],
},
],
},
],
},
],
},
];
const extras = new Map<string, CrossProviderRequirementExtras>([
[
"A&A-01 - Audit Policy",
{
requirementId: "A&A-01",
providers: { aws: "FAIL" },
checkIdsByProvider: { aws: ["check_a"] },
scanIdsByProvider: { aws: ["scan-1"] },
},
],
]);
describe("toCrossProviderAccordionItems", () => {
const items = toCrossProviderAccordionItems(data, extras, "CSA-CCM");
it("keeps the per-scan accordion key scheme so ?section= deep links work", () => {
expect(items).toHaveLength(1);
expect(items[0].key).toBe("CSA-CCM-Audit & Assurance");
expect(items[0].items).toHaveLength(2);
});
it("shows the status only once via the provider chips (no duplicate roll-up badge)", () => {
// A&A-01 has a single provider (aws FAIL): its status must appear once,
// in the chip — not also as a separate roll-up badge.
const { unmount } = render(<>{items[0].items?.[0].title}</>);
expect(screen.getByText("A&A-01 - Audit Policy")).toBeInTheDocument();
expect(screen.getAllByText(/^fail$/i)).toHaveLength(1);
unmount();
});
it("falls back to a single roll-up badge when a requirement has no per-provider breakdown", () => {
// A&A-02 is absent from the extras map, so it keeps one roll-up status.
render(<>{items[0].items?.[1].title}</>);
expect(
screen.getByText("A&A-02 - Independent Assessments"),
).toBeInTheDocument();
expect(screen.getAllByText(/^pass$/i)).toHaveLength(1);
});
});
@@ -0,0 +1,351 @@
import { describe, expect, it, vi } from "vitest";
// The mapper registry transitively imports the client accordion chain, which
// pulls server-only code (next-auth → next/server) into vitest. Stub the JSX
// leaves — mapComplianceData, the code under test here, never touches them.
// Same approach as lib/compliance/compliance-mapper.test.ts.
const { stubComponent } = vi.hoisted(() => ({
stubComponent: () => () => null,
}));
vi.mock(
"@/components/compliance/compliance-custom-details/asd-essential-eight-details",
() => ({ ASDEssentialEightCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/aws-well-architected-details",
() => ({ AWSWellArchitectedCustomDetails: stubComponent() }),
);
vi.mock("@/components/compliance/compliance-custom-details/c5-details", () => ({
C5CustomDetails: stubComponent(),
}));
vi.mock(
"@/components/compliance/compliance-custom-details/ccc-details",
() => ({ CCCCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/cis-details",
() => ({ CISCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/csa-details",
() => ({ CSACustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/ens-details",
() => ({ ENSCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/generic-details",
() => ({ GenericCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/iso-details",
() => ({ ISOCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/kisa-details",
() => ({ KISACustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/mitre-details",
() => ({ MITRECustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/threat-details",
() => ({ ThreatCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/okta-idaas-stig-details",
() => ({ OktaIDaaSStigCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/cis-controls-details",
() => ({ CISControlsCustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/dora-details",
() => ({ DORACustomDetails: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
() => ({ ComplianceAccordionRequirementTitle: stubComponent() }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-title",
() => ({ ComplianceAccordionTitle: stubComponent() }),
);
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import type { Framework, Requirement } from "@/types/compliance";
import type {
CrossProviderOverviewAttributes,
CrossProviderRequirementData,
} from "../../_types";
import {
buildRequirementExtrasMap,
computeProviderBreakdown,
crossProviderToMapperInput,
invertCheckIdsByProvider,
} from "../cross-provider-adapter";
const buildAttributes = (
overrides: Partial<CrossProviderOverviewAttributes> & {
framework: string;
requirements: CrossProviderRequirementData[];
},
): CrossProviderOverviewAttributes => ({
compliance_id: "test_1.0",
name: "Test Framework",
version: "1.0",
description: "Test description",
compatible_providers: ["aws", "azure", "gcp"],
requested_providers: ["aws", "azure"],
providers: ["aws", "azure"],
scan_ids: ["scan-aws-1", "scan-azure-1"],
scan_ids_by_provider: { aws: ["scan-aws-1"], azure: ["scan-azure-1"] },
requirements_passed: 0,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: overrides.requirements.length,
...overrides,
});
const csaAttributes = buildAttributes({
framework: "CSA-CCM",
requirements: [
{
id: "A&A-01",
name: "Audit and Assurance Policy and Procedures",
description: "Establish audit policies.",
attributes: {
Section: "Audit & Assurance",
CCMLite: "Yes",
IaaS: "Yes",
PaaS: "Yes",
SaaS: "Yes",
ScopeApplicability: "IaaS, PaaS, SaaS",
},
status: "FAIL",
providers: { aws: "FAIL", azure: "PASS" },
check_ids_by_provider: {
aws: ["iam_check_1", "shared_check"],
azure: ["entra_check_1", "shared_check"],
},
},
{
id: "A&A-02",
name: "Independent Assessments",
description: "Conduct independent audits.",
attributes: {
Section: "Audit & Assurance",
CCMLite: "No",
IaaS: "Yes",
PaaS: "No",
SaaS: "No",
ScopeApplicability: "IaaS",
},
status: "MANUAL",
providers: { aws: "MANUAL", azure: "MANUAL" },
check_ids_by_provider: {},
},
{
id: "DSP-01",
name: "Data Security Policies",
description: "Protect data.",
attributes: {
Section: "Data Security and Privacy Lifecycle Management",
CCMLite: "Yes",
IaaS: "Yes",
PaaS: "Yes",
SaaS: "Yes",
ScopeApplicability: "IaaS",
},
status: "PASS",
providers: { aws: "PASS" },
check_ids_by_provider: { aws: ["s3_check_1"] },
},
],
});
const doraAttributes = buildAttributes({
framework: "DORA",
requirements: [
{
id: "RQ-01",
name: "ICT Risk Management",
description: "Manage ICT risk.",
attributes: {
Pillar: "ICT risk management",
Article: "Art. 5",
ArticleTitle: "Governance and organisation",
},
status: "FAIL",
providers: { aws: "FAIL", azure: "FAIL" },
check_ids_by_provider: { aws: ["check_a"], azure: ["check_b"] },
},
],
});
const cisControlsAttributes = buildAttributes({
framework: "CIS-Controls",
requirements: [
{
id: "1.1",
name: "Establish and Maintain Detailed Enterprise Asset Inventory",
description: "Maintain an asset inventory.",
attributes: {
Section: "01 Inventory and Control of Enterprise Assets",
Function: "Identify",
AssetType: "Devices",
ImplementationGroups: ["IG1", "IG2", "IG3"],
},
status: "PASS",
providers: { aws: "PASS" },
check_ids_by_provider: { aws: ["ec2_check"] },
},
],
});
const collectRequirements = (frameworks: Framework[]): Requirement[] =>
frameworks.flatMap((framework) =>
framework.categories.flatMap((category) =>
category.controls.flatMap((control) => control.requirements),
),
);
describe.each([
["CSA-CCM", csaAttributes],
["DORA", doraAttributes],
["CIS-Controls", cisControlsAttributes],
])("crossProviderToMapperInput through the real %s mapper", (key, attrs) => {
it("preserves the cross-provider requirement contract", () => {
const attributes = attrs as CrossProviderOverviewAttributes;
const mapper = getComplianceMapper(key);
const { attributesData, requirementsData } =
crossProviderToMapperInput(attributes);
const mapped = collectRequirements(
mapper.mapComplianceData(attributesData, requirementsData),
);
const extras = buildRequirementExtrasMap(attributes);
const statuses = Object.fromEntries(mapped.map((r) => [r.name, r.status]));
expect(mapped).toHaveLength(attributes.requirements.length);
for (const requirement of attributes.requirements) {
const composedName = `${requirement.id} - ${requirement.name}`;
const mappedRequirement = mapped.find((r) => r.name === composedName);
const expectedCheckIds = new Set(
Object.values(requirement.check_ids_by_provider ?? {}).flat(),
);
expect(statuses[composedName]).toBe(requirement.status);
expect(
extras.get(composedName),
`no extras for "${composedName}"`,
).toBeDefined();
expect(extras.get(composedName)?.scanIdsByProvider).toEqual(
attributes.scan_ids_by_provider,
);
expect(new Set(mappedRequirement?.check_ids)).toEqual(expectedCheckIds);
expect(mappedRequirement?.check_ids).toHaveLength(expectedCheckIds.size);
}
});
});
describe("CSA grouping through the real mapper", () => {
it("groups requirements by Section into categories with correct counters", () => {
const mapper = getComplianceMapper("CSA-CCM");
const { attributesData, requirementsData } =
crossProviderToMapperInput(csaAttributes);
const [framework] = mapper.mapComplianceData(
attributesData,
requirementsData,
);
expect(framework.name).toBe("CSA-CCM");
expect(framework.categories.map((c) => c.name)).toEqual([
"Audit & Assurance",
"Data Security and Privacy Lifecycle Management",
]);
expect(framework.pass).toBe(1);
expect(framework.fail).toBe(1);
expect(framework.manual).toBe(1);
});
});
describe("invertCheckIdsByProvider", () => {
it("maps exclusive and shared checks to their provider types", () => {
expect(
invertCheckIdsByProvider({
aws: ["shared_check", "iam_check_1"],
azure: ["shared_check"],
}),
).toEqual({
shared_check: ["aws", "azure"],
iam_check_1: ["aws"],
});
});
});
describe("computeProviderBreakdown", () => {
it("tallies per-provider statuses and flags compatible-but-unscanned providers", () => {
const breakdown = computeProviderBreakdown(csaAttributes);
const aws = breakdown.find((entry) => entry.provider === "aws");
expect(aws).toEqual({
provider: "aws",
pass: 1,
fail: 1,
manual: 1,
total: 3,
score: 50,
unscanned: false,
});
const azure = breakdown.find((entry) => entry.provider === "azure");
expect(azure?.pass).toBe(1);
expect(azure?.fail).toBe(0);
expect(azure?.manual).toBe(1);
expect(azure?.score).toBe(100);
const gcp = breakdown.find((entry) => entry.provider === "gcp");
expect(gcp?.unscanned).toBe(true);
expect(gcp?.total).toBe(0);
});
it("lists scanned providers first, each group alphabetically", () => {
const breakdown = computeProviderBreakdown(
buildAttributes({
framework: "CSA-CCM",
compatible_providers: ["oraclecloud", "gcp", "azure", "aws"],
providers: ["gcp", "aws"],
requirements: [],
}),
);
expect(breakdown.map((entry) => entry.provider)).toEqual([
"aws",
"gcp",
"azure",
"oraclecloud",
]);
});
it("ignores provider types the UI does not know", () => {
const breakdown = computeProviderBreakdown(
buildAttributes({
framework: "CSA-CCM",
compatible_providers: ["aws", "not-a-provider"],
requirements: [],
}),
);
expect(breakdown.map((entry) => entry.provider)).toEqual(["aws"]);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { PROVIDER_TYPES } from "@/types/providers";
import {
buildCrossProviderDetailHref,
CROSS_PROVIDER_FRAMEWORKS,
resolveCrossProviderFramework,
} from "../cross-provider-frameworks";
describe("CROSS_PROVIDER_FRAMEWORKS catalog", () => {
it("uses titles that resolve to a compliance icon", () => {
for (const entry of CROSS_PROVIDER_FRAMEWORKS) {
expect(getComplianceIcon(entry.title), entry.title).not.toBeNull();
}
});
it("only lists providers the UI knows how to render", () => {
for (const entry of CROSS_PROVIDER_FRAMEWORKS) {
for (const provider of entry.compatibleProviders) {
expect(PROVIDER_TYPES).toContain(provider);
}
expect(new Set(entry.compatibleProviders).size).toBe(
entry.compatibleProviders.length,
);
}
});
});
describe("resolveCrossProviderFramework", () => {
it.each([
[undefined, "CSA-CCM"],
["csa_ccm_4.0", "DORA"],
["csa_ccm_4.0", "csa-ccm"],
])("rejects invalid detail links", (complianceId, title) => {
expect(resolveCrossProviderFramework(complianceId, title)).toBeUndefined();
});
it("resolves the catalog entry for a valid detail link", () => {
// Given
const expected = CROSS_PROVIDER_FRAMEWORKS[0];
// When
const framework = resolveCrossProviderFramework(
expected.complianceId,
expected.title,
);
// Then
expect(framework).toEqual(expected);
});
});
describe("buildCrossProviderDetailHref", () => {
const entry = CROSS_PROVIDER_FRAMEWORKS[0];
it("builds the detail path with cross-provider mode and identity params", () => {
const href = buildCrossProviderDetailHref(entry);
expect(href).toBe(
`/compliance/${encodeURIComponent(entry.title)}?mode=cross-provider&complianceId=${encodeURIComponent(entry.complianceId)}&version=${encodeURIComponent(entry.version)}`,
);
});
it("forwards only the cross-provider filter params present in searchParams", () => {
const href = buildCrossProviderDetailHref(entry, {
"filter[provider_type__in]": "aws,gcp",
"filter[provider_id__in]": "prov-1",
"filter[provider_groups__in]": "group-1",
"filter[region__in]": "eu-west-1",
"filter[cis_profile_level]": "Level 1",
scanId: "scan-1",
tab: "cross-provider",
});
const url = new URL(href, "https://localhost");
expect(url.searchParams.get("mode")).toBe("cross-provider");
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp");
expect(url.searchParams.get("filter[provider_id__in]")).toBe("prov-1");
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
expect(url.searchParams.has("filter[region__in]")).toBe(false);
expect(url.searchParams.has("filter[cis_profile_level]")).toBe(false);
expect(url.searchParams.has("scanId")).toBe(false);
expect(url.searchParams.has("tab")).toBe(false);
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../_actions/cross-provider", () => ({
getCrossProviderPdfBinary: vi.fn(),
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: vi.fn(),
ToastAction: () => null,
}));
vi.mock("@/lib/helper", () => ({
downloadFile: vi.fn(),
}));
import { buildCrossProviderPdfTaskScope } from "../cross-provider-pdf";
describe("buildCrossProviderPdfTaskScope", () => {
it("normalizes set-like filter ordering into one task scope", () => {
// Given / When
const first = buildCrossProviderPdfTaskScope("csa_ccm_4.0", {
scanIds: ["scan-2", "scan-1"],
providerTypes: "gcp,aws",
providerIds: "provider-2,provider-1",
});
const second = buildCrossProviderPdfTaskScope("csa_ccm_4.0", {
scanIds: ["scan-1", "scan-2"],
providerTypes: "aws,gcp",
providerIds: "provider-1,provider-2",
});
// Then
expect(first).toBe(second);
});
it("keeps reports from different provider groups in separate scopes", () => {
// Given / When
const productionScope = buildCrossProviderPdfTaskScope("csa_ccm_4.0", {
scanIds: ["scan-1"],
providerGroups: "production",
});
const developmentScope = buildCrossProviderPdfTaskScope("csa_ccm_4.0", {
scanIds: ["scan-1"],
providerGroups: "development",
});
// Then
expect(productionScope).not.toBe(developmentScope);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildSearchParamsKey } from "../search-params-key";
describe("buildSearchParamsKey", () => {
it("ignores table-state params so paginating or sorting never remounts the view", () => {
const base = {
complianceId: "comp-1",
scanId: "scan-1",
"filter[region__in]": "eu-west-1",
};
const withTableState = {
...base,
page: "3",
pageSize: "25",
sort: "-severity",
};
expect(buildSearchParamsKey(withTableState)).toBe(
buildSearchParamsKey(base),
);
});
it("changes when a non-table param changes", () => {
expect(
buildSearchParamsKey({ complianceId: "comp-1", scanId: "scan-1" }),
).not.toBe(
buildSearchParamsKey({ complianceId: "comp-1", scanId: "scan-2" }),
);
});
});
@@ -0,0 +1,82 @@
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
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.
*/
export const toCrossProviderAccordionItems = (
data: Framework[],
extras: Map<string, CrossProviderRequirementExtras>,
framework: string,
): AccordionItemProps[] =>
data.flatMap((frameworkData) =>
frameworkData.categories.map((category) => ({
key: `${frameworkData.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.flatMap((control) =>
control.requirements.map((requirement, reqIndex) => {
const requirementExtras = extras.get(requirement.name as string);
return {
key: `${frameworkData.name}-${category.name}-req-${reqIndex}`,
title: (
<div className="flex w-full items-center justify-between gap-3">
<span className="min-w-0 truncate">
{requirement.name as string}
</span>
{/* Status shown once: the per-provider chips carry each
provider's status; only fall back to a roll-up badge when
no per-provider breakdown exists. */}
{requirementExtras ? (
<RequirementProviderChips
providers={requirementExtras.providers}
/>
) : (
<StatusFindingBadge
status={requirement.status as FindingStatus}
/>
)}
</div>
),
content: requirementExtras ? (
<CrossProviderRequirementContent
requirement={requirement}
extras={requirementExtras}
framework={framework}
/>
) : (
<p className="text-sm">
No per-provider breakdown is available for this requirement.
</p>
),
items: [],
};
}),
),
})),
);
@@ -0,0 +1,168 @@
import type {
AttributesData,
AttributesItemData,
CheckProviderTypesMap,
RequirementItemData,
RequirementsData,
} from "@/types/compliance";
import { isKnownProviderType, PROVIDER_TYPES } from "@/types/providers";
import type {
CrossProviderOverviewAttributes,
CrossProviderRequirementExtras,
ProviderBreakdownEntry,
ProviderCheckIdsMap,
} from "../_types";
/** The composed display name the per-scan mappers give every requirement.
* Shared with `buildRequirementExtrasMap` so the extras join never parses
* strings back apart. */
const composeRequirementName = (id: string, name: string): string =>
name ? `${id} - ${name}` : id;
/**
* Convert a cross-provider overview into the `{AttributesData,
* RequirementsData}` pair the per-scan compliance mappers consume, so the
* existing framework mappers (grouping, counters, detail fields) render the
* requirements accordion without a parallel cross-provider pipeline.
*
* The mappers read `attributes.attributes.metadata[0]` for framework-specific
* fields (Section, Pillar, …), so each requirement's flat `attributes` dict is
* wrapped in a single-element array. `check_ids` is the deduped union across
* providers — per-provider splits travel separately via
* {@link buildRequirementExtrasMap}.
*/
export const crossProviderToMapperInput = (
attrs: CrossProviderOverviewAttributes,
): { attributesData: AttributesData; requirementsData: RequirementsData } => {
const attributeItems: AttributesItemData[] = [];
const requirementItems: RequirementItemData[] = [];
for (const requirement of attrs.requirements) {
const allCheckIds = Array.from(
new Set(
Object.values(requirement.check_ids_by_provider ?? {}).flatMap(
(ids) => ids ?? [],
),
),
);
attributeItems.push({
type: "compliance-requirements-attributes",
id: requirement.id,
attributes: {
framework_description: attrs.description || "",
name: requirement.name,
framework: attrs.framework,
version: attrs.version || "",
description: requirement.description || "",
attributes: {
metadata: [
requirement.attributes,
] as AttributesItemData["attributes"]["attributes"]["metadata"],
check_ids: allCheckIds,
},
},
});
requirementItems.push({
type: "compliance-requirements-details",
id: requirement.id,
attributes: {
framework: attrs.framework,
version: attrs.version || "",
description: requirement.description || "",
status: requirement.status,
},
});
}
return {
attributesData: { data: attributeItems },
requirementsData: { data: requirementItems },
};
};
/**
* Cross-provider context for each requirement, keyed by the exact composed
* name the mappers produce, so renderers can join per-provider statuses and
* scan/check splits onto mapped requirements without touching the mappers.
*/
export const buildRequirementExtrasMap = (
attrs: CrossProviderOverviewAttributes,
): Map<string, CrossProviderRequirementExtras> => {
const extras = new Map<string, CrossProviderRequirementExtras>();
for (const requirement of attrs.requirements) {
extras.set(composeRequirementName(requirement.id, requirement.name), {
requirementId: requirement.id,
providers: requirement.providers,
checkIdsByProvider: requirement.check_ids_by_provider ?? {},
scanIdsByProvider: attrs.scan_ids_by_provider,
});
}
return extras;
};
/**
* Invert a requirement's per-provider check split into check id → provider
* types, so the combined checks list can label each check. Iterates
* PROVIDER_TYPES for a stable icon order regardless of API key order.
*/
export const invertCheckIdsByProvider = (
checkIdsByProvider: ProviderCheckIdsMap,
): CheckProviderTypesMap => {
const byCheck: CheckProviderTypesMap = {};
for (const provider of PROVIDER_TYPES) {
for (const checkId of checkIdsByProvider[provider] ?? []) {
(byCheck[checkId] ??= []).push(provider);
}
}
return byCheck;
};
/**
* Per-provider score summary for the coverage panel and framework cards.
* Scanned providers come first, each group sorted alphabetically; entries the
* UI cannot render (unknown provider types) are dropped. Score is the pass
* percentage over non-manual requirements the provider contributed.
*/
export const computeProviderBreakdown = (
attrs: CrossProviderOverviewAttributes,
): ProviderBreakdownEntry[] => {
const contributing = new Set(attrs.providers);
return attrs.compatible_providers
.filter(isKnownProviderType)
.sort((a, b) => {
const aScanned = contributing.has(a);
if (aScanned !== contributing.has(b)) return aScanned ? -1 : 1;
return a.localeCompare(b);
})
.map((provider) => {
let pass = 0;
let fail = 0;
let manual = 0;
for (const requirement of attrs.requirements) {
const status = requirement.providers[provider];
if (status === "PASS") pass += 1;
else if (status === "FAIL") fail += 1;
else if (status === "MANUAL") manual += 1;
}
const scored = pass + fail;
return {
provider,
pass,
fail,
manual,
total: pass + fail + manual,
score: scored > 0 ? Math.round((pass / scored) * 100) : 0,
unscanned: !contributing.has(provider),
};
});
};
@@ -0,0 +1,114 @@
import type { KnownProviderType } from "@/types/providers";
import type { CrossProviderApiFilters } from "../_types";
// Catalog of universal compliance frameworks served by the cross-provider
// endpoint. Hardcoded because the API has no listing endpoint for universal
// framework ids: when a new universal JSON ships in the SDK
// (prowler/compliance/<framework>.json), add an entry here.
export interface CrossProviderFrameworkEntry {
/** Universal framework id used as filter[compliance_id]. */
complianceId: string;
/** Card/detail title; also the [compliancetitle] path segment and the
* key getComplianceIcon resolves the framework icon from. */
title: string;
version: string;
description: string;
/** Static fallback for the per-provider chips; the API response's
* compatible_providers is authoritative at runtime. */
compatibleProviders: KnownProviderType[];
}
export const CROSS_PROVIDER_FRAMEWORKS: CrossProviderFrameworkEntry[] = [
{
complianceId: "csa_ccm_4.0",
title: "CSA-CCM",
version: "4.0",
description:
"CSA Cloud Controls Matrix v4.0 — a cybersecurity control framework with 197 control objectives across 17 domains.",
compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "oraclecloud"],
},
{
complianceId: "cis_controls_8.1",
title: "CIS-Controls",
version: "8.1",
description:
"CIS Critical Security Controls v8.1 — prioritized safeguards organized into 18 controls to mitigate the most prevalent cyber-attacks.",
compatibleProviders: [
"aws",
"azure",
"gcp",
"m365",
"kubernetes",
"github",
"googleworkspace",
"okta",
"oraclecloud",
"alibabacloud",
"cloudflare",
"mongodbatlas",
"openstack",
"vercel",
],
},
{
complianceId: "dora_2022_2554",
title: "DORA",
version: "2022/2554",
description:
"Digital Operational Resilience Act (EU 2022/2554) — the EU framework for the digital operational resilience of the financial sector.",
compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"],
},
];
/** Resolves only canonical catalog links. Missing, unknown, or mismatched
* identities must not reach the API as an `undefined` or unrelated filter. */
export const resolveCrossProviderFramework = (
complianceId: string | undefined,
title: string,
): CrossProviderFrameworkEntry | undefined =>
CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === complianceId && entry.title === title,
);
/** Cross-provider filter params forwarded from the overview into detail
* links (and consumed back by the detail page). */
const CROSS_PROVIDER_FILTER_PARAMS = [
"filter[provider_type__in]",
"filter[provider_id__in]",
"filter[provider_groups__in]",
] as const;
/** Parses the URL filter params every cross-provider endpoint accepts. Kept
* next to CROSS_PROVIDER_FILTER_PARAMS so the overview and detail islands
* build identical, typed filter objects. */
export const parseCrossProviderFilters = (
searchParams: Record<string, string | string[] | undefined>,
): CrossProviderApiFilters => ({
providerTypes:
searchParams["filter[provider_type__in]"]?.toString() || undefined,
providerIds: searchParams["filter[provider_id__in]"]?.toString() || undefined,
providerGroups:
searchParams["filter[provider_groups__in]"]?.toString() || undefined,
});
export const buildCrossProviderDetailHref = (
entry: Pick<
CrossProviderFrameworkEntry,
"complianceId" | "title" | "version"
>,
searchParams?: Record<string, string | string[] | undefined>,
): string => {
const params = new URLSearchParams();
params.set("mode", "cross-provider");
params.set("complianceId", entry.complianceId);
params.set("version", entry.version);
for (const key of CROSS_PROVIDER_FILTER_PARAMS) {
const value = searchParams?.[key]?.toString();
if (value) params.set(key, value);
}
return `/compliance/${encodeURIComponent(entry.title)}?${params.toString()}`;
};
@@ -0,0 +1,91 @@
"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";
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({
complianceId,
scanIds: [...(filters.scanIds ?? [])].sort(),
providerTypes: normalizeCommaSeparatedFilter(filters.providerTypes),
providerIds: normalizeCommaSeparatedFilter(filters.providerIds),
providerGroups: normalizeCommaSeparatedFilter(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<void> => {
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.",
});
}
};
/**
* 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: (
<ToastAction
altText="Download report"
onClick={() => downloadCrossProviderPdf(task.taskId)}
>
Download
</ToastAction>
),
});
},
onError: (task) => {
toast({
variant: "destructive",
title: "Report generation failed",
description:
task.error ||
"The cross-provider PDF could not be generated. Try again later.",
});
},
};
@@ -0,0 +1,19 @@
/** Params the findings DataTable pushes to the URL. They must not remount the
* detail view: the client accordion would collapse and lose its state, while
* the findings hook already refetches from useSearchParams on its own. */
const TABLE_STATE_PARAMS = ["page", "pageSize", "sort"];
/**
* Suspense key for the compliance detail views, stable across table-state
* navigations (pagination/sort) so only real query changes remount the tree.
*/
export const buildSearchParamsKey = (
searchParams: Record<string, string | string[] | undefined>,
): string =>
JSON.stringify(
Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => !TABLE_STATE_PARAMS.includes(key),
),
),
);
+163
View File
@@ -0,0 +1,163 @@
import type { ActionErrorResult } from "@/lib/action-errors";
import type { RequirementStatus } from "@/types/compliance";
import type { KnownProviderType, ProviderType } from "@/types/providers";
// Types for the Cloud-only cross-provider compliance roll-up, backed by
// GET /cross-provider-compliance-overviews (one universal framework
// aggregated across one scan per compatible provider; roll-up status is
// computed server-side as FAIL > PASS > MANUAL).
export const COMPLIANCE_TAB = {
PER_SCAN: "per-scan",
CROSS_PROVIDER: "cross-provider",
} as const;
export type ComplianceTab =
(typeof COMPLIANCE_TAB)[keyof typeof COMPLIANCE_TAB];
export const CROSS_PROVIDER_OVERVIEW_TYPE =
"cross-provider-compliance-overviews" as const;
/** Roll-up statuses the endpoint emits (never "No findings"). */
export type CrossProviderStatus = Extract<
RequirementStatus,
"PASS" | "FAIL" | "MANUAL"
>;
export type ProviderStatusMap = Partial<
Record<ProviderType, CrossProviderStatus>
>;
export type ProviderCheckIdsMap = Partial<Record<ProviderType, string[]>>;
export type ProviderScanIdsMap = Partial<Record<ProviderType, string[]>>;
export interface CrossProviderRequirementData {
id: string;
name: string;
description: string;
/** Free-form per-requirement metadata from the universal JSON (e.g. CSA
* exposes Section/CCMLite; DORA exposes Chapter/Article). */
attributes: Record<string, unknown>;
status: CrossProviderStatus;
providers: ProviderStatusMap;
check_ids_by_provider?: ProviderCheckIdsMap;
}
export interface CrossProviderOverviewAttributes {
compliance_id: string;
framework: string;
name: string;
version: string;
description: string;
/** Provider types the universal framework declares checks for. */
compatible_providers: string[];
/** Provider types of the scans used as aggregation input. */
requested_providers: string[];
/** Provider types that contributed at least one row after RBAC/filters. */
providers: string[];
scan_ids: string[];
/** Provider type → scan UUIDs aggregated (a type can have N accounts). */
scan_ids_by_provider: ProviderScanIdsMap;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
requirements: CrossProviderRequirementData[];
}
export interface CrossProviderOverviewData {
type: typeof CROSS_PROVIDER_OVERVIEW_TYPE;
id: string;
attributes: CrossProviderOverviewAttributes;
}
export interface CrossProviderOverviewResponse {
data: CrossProviderOverviewData;
}
export const CROSS_PROVIDER_OVERVIEW_RESULT_STATUS = {
SUCCESS: "success",
ACTION_ERROR: "action-error",
LOAD_ERROR: "load-error",
} as const;
export type CrossProviderOverviewResultStatus =
(typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS)[keyof typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS];
export const CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE =
"Could not load cross-provider compliance data. Try again later.";
export interface CrossProviderOverviewSuccessResult {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS;
response: CrossProviderOverviewResponse;
}
export interface CrossProviderOverviewActionErrorResult {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR;
result: ActionErrorResult;
}
export interface CrossProviderOverviewLoadErrorResult {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR;
message: string;
}
export type CrossProviderOverviewResult =
| CrossProviderOverviewSuccessResult
| CrossProviderOverviewActionErrorResult
| CrossProviderOverviewLoadErrorResult;
/** Filters accepted by every cross-provider endpoint (comma-joined). */
export interface CrossProviderApiFilters {
scanIds?: string[];
providerTypes?: string;
providerIds?: string;
providerGroups?: string;
}
/** Cross-provider context joined onto a mapped requirement, keyed by the
* composed requirement name the per-scan mappers produce. */
export interface CrossProviderRequirementExtras {
requirementId: string;
providers: ProviderStatusMap;
checkIdsByProvider: ProviderCheckIdsMap;
scanIdsByProvider: ProviderScanIdsMap;
}
/** Card-ready framework roll-up shared by the overview grid (producer) and
* `CrossProviderFrameworkCard` (props), so the `{...summary}` spread can
* never drift between the two. */
export interface CrossProviderFrameworkSummary {
complianceId: string;
title: string;
version: string;
description: string;
requirementsPassed: number;
requirementsFailed: number;
requirementsManual: number;
totalRequirements: number;
providerBreakdown: ProviderBreakdownEntry[];
}
export interface ProviderBreakdownEntry {
/** Narrowed to the known set: the breakdown only renders providers the UI
* ships display names and icons for. */
provider: KnownProviderType;
pass: number;
fail: number;
manual: number;
total: number;
/** 0-100 pass percentage over non-manual requirements. */
score: number;
/** Compatible with the framework but no scan contributed. */
unscanned: boolean;
}
/** A previously generated PDF matching the current filters, downloadable
* immediately without polling. */
export interface LatestCrossProviderPdf {
taskId: string;
filename?: string;
completedAt?: string;
}
+99 -43
View File
@@ -18,6 +18,7 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
ScanEntity,
@@ -26,6 +27,11 @@ import {
} from "@/types";
import { ComplianceOverviewData } from "@/types/compliance";
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
import { CrossProviderOverview } from "./_components/cross-provider-overview";
import { COMPLIANCE_TAB } from "./_types";
export default async function Compliance({
searchParams,
}: {
@@ -34,6 +40,44 @@ export default async function Compliance({
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
// Cross-Provider is Prowler Cloud-only (the OSS API has no
// cross-provider-compliance-overviews endpoint): in OSS the tab renders
// disabled with the upsell badge and Per Scan is forced active.
const crossProviderEnabled = isCloud();
const activeTab = crossProviderEnabled
? getComplianceTab(resolvedSearchParams.tab)
: COMPLIANCE_TAB.PER_SCAN;
// Only the active tab's payload is built: switching tabs is a real
// navigation, so pre-building the inactive tab buys nothing.
if (activeTab === COMPLIANCE_TAB.CROSS_PROVIDER) {
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={{ flowId: "view-compliance" }}
>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
perScanContent={null}
crossProviderContent={
<Suspense
key={`cross-provider-${searchParamsKey}`}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<CrossProviderOverview searchParams={resolvedSearchParams} />
</Suspense>
}
/>
</ContentLayout>
);
}
const scansData = await getScans({
filters: {
"filter[state]": "completed",
@@ -56,7 +100,12 @@ export default async function Compliance({
useFallback: true,
}}
>
<NoScansAvailable />
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
perScanContent={<NoScansAvailable />}
crossProviderContent={null}
/>
</ContentLayout>
);
}
@@ -140,54 +189,61 @@ export default async function Compliance({
}
}
const perScanContent = selectedScanId ? (
<>
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
/>
</div>
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
<div className="mb-6">
<ThreatScoreBadge
score={threatScoreData.score}
scanId={selectedScanId}
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
/>
</div>
)}
<Suspense
key={searchParamsKey}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
</>
) : (
<NoScansAvailable />
);
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
{selectedScanId ? (
<>
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
/>
</div>
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
<div className="mb-6">
<ThreatScoreBadge
score={threatScoreData.score}
scanId={selectedScanId}
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
/>
</div>
)}
<Suspense
key={searchParamsKey}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
</>
) : (
<NoScansAvailable />
)}
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
perScanContent={perScanContent}
crossProviderContent={null}
/>
</ContentLayout>
);
}
+4
View File
@@ -15,6 +15,7 @@ import {
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
import { TaskPollingWatcher } from "@/components/shared/task-polling-watcher";
import { fontMono, fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { isCloud } from "@/lib/shared/env";
@@ -106,6 +107,9 @@ export default async function RootLayout({
</>
)}
<MainLayout>{children}</MainLayout>
{/* Resumes persisted background-task polling (e.g. cross-provider
PDF generation) so completion toasts survive hard reloads. */}
<TaskPollingWatcher />
<Toaster />
</Providers>
</body>
@@ -0,0 +1,36 @@
"use client";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import type { CheckProviderTypesMap } from "@/types/compliance";
interface ChecksWithProvidersProps {
checks: string[];
checkProviders: CheckProviderTypesMap;
}
/**
* Provider-labeled check list for the cross-provider requirement view: each
* check id followed by the icons of the provider types it belongs to. Checks
* missing from the map render without icons.
*/
export const ChecksWithProviders = ({
checks,
checkProviders,
}: ChecksWithProvidersProps) => (
<ul className="flex flex-wrap gap-x-4 gap-y-1">
{checks.map((checkId) => (
<li
key={checkId}
data-testid={`check-with-providers-${checkId}`}
className="flex items-center gap-1.5"
>
<span className="text-gray-600 dark:text-gray-200">{checkId}</span>
<span className="flex items-center gap-1">
{(checkProviders[checkId] ?? []).map((type) => (
<ProviderTypeIcon key={type} type={type} size={14} />
))}
</span>
</li>
))}
</ul>
);
@@ -18,23 +18,31 @@ import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { Requirement } from "@/types/compliance";
import { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { ChecksWithProviders } from "./checks-with-providers";
import { useRequirementFindings } from "./use-requirement-findings";
interface ClientAccordionContentProps {
requirement: Requirement;
scanId: string;
/** Single scan (per-scan compliance view). Ignored when `scanIds` is set. */
scanId?: string;
/** All scans to query at once (cross-provider view). */
scanIds?: string[];
framework: string;
disableFindings?: boolean;
/** When set, the checks list labels each check with its provider icons. */
checkProviders?: CheckProviderTypesMap;
}
export const ClientAccordionContent = ({
requirement,
framework,
scanId,
scanIds,
disableFindings = false,
checkProviders,
}: ClientAccordionContentProps) => {
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
@@ -49,6 +57,7 @@ export const ClientAccordionContent = ({
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
const checks = requirement.check_ids || [];
const resolvedScanIds = scanIds ?? (scanId ? [scanId] : []);
const {
findings,
@@ -63,7 +72,7 @@ export const ClientAccordionContent = ({
checks.length > 0 &&
requirement.status !== "No findings",
checkIds: checks,
scanId,
scanIds: resolvedScanIds,
pageNumber,
pageSize,
sort,
@@ -111,7 +120,14 @@ export const ClientAccordionContent = ({
<div className="w-full flex-col">
<div className="mt-[-8px] mb-1 h-1 w-full border-b border-gray-200 dark:border-gray-800" />
<span className="text-gray-600 dark:text-gray-200" aria-label="Checks">
{checks.join(", ")}
{checkProviders ? (
<ChecksWithProviders
checks={checks}
checkProviders={checkProviders}
/>
) : (
checks.join(", ")
)}
</span>
</div>
</div>
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import { Button } from "@/components/shadcn";
import { Accordion, AccordionItemProps } from "@/components/shadcn";
import { Card } from "@/components/shadcn/card/card";
export const ClientAccordionWrapper = ({
items,
@@ -83,16 +84,17 @@ export const ClientAccordionWrapper = ({
});
};
// Same enclosing-card design as the app's tables: the expand toggle sits
// top-right inside the card and the accordion fills the rest.
return (
<div ref={containerRef}>
<Card ref={containerRef} variant="base" className="w-full gap-2">
{!hideExpandButton && (
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary flex justify-end text-xs font-medium transition-colors">
<Button
onClick={handleToggleExpand}
aria-label={isExpanded ? "Collapse all" : "Expand all"}
variant="ghost"
size="sm"
className="mb-1"
>
{isExpanded ? "Collapse all" : "Expand all"}
</Button>
@@ -105,6 +107,6 @@ export const ClientAccordionWrapper = ({
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
</div>
</Card>
);
};
@@ -52,7 +52,7 @@ function defaultOptions(overrides?: Record<string, unknown>) {
return {
enabled: true,
checkIds: ["check_1", "check_2"],
scanId: "scan-1",
scanIds: ["scan-1"],
pageNumber: "1",
pageSize: "10",
sort: "+severity",
@@ -84,7 +84,7 @@ describe("useRequirementFindings", () => {
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith({
filters: {
"filter[check_id__in]": "check_1,check_2",
"filter[scan]": "scan-1",
"filter[scan__in]": "scan-1",
"filter[muted]": "false",
},
page: 1,
@@ -93,6 +93,24 @@ describe("useRequirementFindings", () => {
});
});
it("should query all scans at once when given several scan ids", async () => {
// Given / When
renderHook(() =>
useRequirementFindings(defaultOptions({ scanIds: ["scan-1", "scan-2"] })),
);
await flushAsync();
// Then — one combined request, not one per scan.
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
"filter[scan__in]": "scan-1,scan-2",
}),
}),
);
});
it("should expand findings with their included scan, resource, and provider", async () => {
// Given / When
const { result } = renderHook(() =>
@@ -110,12 +128,13 @@ describe("useRequirementFindings", () => {
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
});
it("should not fetch when disabled or without check ids", async () => {
it("should not fetch when disabled, without check ids, or without scan ids", async () => {
// Given / When
renderHook(() =>
useRequirementFindings(defaultOptions({ enabled: false })),
);
renderHook(() => useRequirementFindings(defaultOptions({ checkIds: [] })));
renderHook(() => useRequirementFindings(defaultOptions({ scanIds: [] })));
await flushAsync();
// Then
@@ -130,11 +149,15 @@ describe("useRequirementFindings", () => {
const withoutChecks = renderHook(() =>
useRequirementFindings(defaultOptions({ checkIds: [] })),
);
const withoutScans = renderHook(() =>
useRequirementFindings(defaultOptions({ scanIds: [] })),
);
await flushAsync();
// Then — a skipped fetch must not look like a pending one.
expect(disabled.result.current.isLoading).toBe(false);
expect(withoutChecks.result.current.isLoading).toBe(false);
expect(withoutScans.result.current.isLoading).toBe(false);
});
it("should report loading until the fetch settles", async () => {
@@ -11,7 +11,7 @@ import type { UpdateFindingTriageInput } from "@/types/findings-triage";
interface UseRequirementFindingsOptions {
enabled: boolean;
checkIds: string[];
scanId: string;
scanIds: string[];
pageNumber: string;
pageSize: string;
sort: string;
@@ -33,7 +33,7 @@ const FINDINGS_LOAD_ERROR = "Could not load findings.";
export function useRequirementFindings({
enabled,
checkIds,
scanId,
scanIds,
pageNumber,
pageSize,
sort,
@@ -45,10 +45,12 @@ export function useRequirementFindings({
const [error, setError] = useState<string | null>(null);
const [reloadNonce, setReloadNonce] = useState(0);
// Depend on the joined value, not the array: the requirement prop gets a
// Depend on the joined values, not the arrays: the requirement prop gets a
// fresh identity on every parent render and must not retrigger the fetch.
const checkIdsKey = checkIds.join(",");
const isFetchEnabled = enabled && checkIdsKey.length > 0;
const scanIdsKey = scanIds.join(",");
const isFetchEnabled =
enabled && checkIdsKey.length > 0 && scanIdsKey.length > 0;
// A skipped fetch is not a pending one; without this the caller would show
// a skeleton forever for requirements whose fetch never runs.
const isLoading = isFetchEnabled && findings === null && error === null;
@@ -66,7 +68,7 @@ export function useRequirementFindings({
const findingsData = await getFindings({
filters: {
"filter[check_id__in]": checkIdsKey,
"filter[scan]": scanId,
"filter[scan__in]": scanIdsKey,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
@@ -116,7 +118,7 @@ export function useRequirementFindings({
}, [
isFetchEnabled,
checkIdsKey,
scanId,
scanIdsKey,
pageNumber,
pageSize,
sort,
@@ -34,6 +34,9 @@ interface ComplianceDownloadContainerProps {
orientation?: "row" | "column";
buttonWidth?: "auto" | "icon";
presentation?: "buttons" | "dropdown";
/** Custom dropdown trigger (e.g. an outline "Report" button); only used
* when presentation is "dropdown". Defaults to the dots icon. */
dropdownTrigger?: React.ReactNode;
}
export const ComplianceDownloadContainer = ({
@@ -45,6 +48,7 @@ export const ComplianceDownloadContainer = ({
orientation = "row",
buttonWidth = "auto",
presentation = "buttons",
dropdownTrigger,
}: ComplianceDownloadContainerProps) => {
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false);
@@ -116,6 +120,7 @@ export const ComplianceDownloadContainer = ({
<ActionDropdown
variant={isIconWidth ? "bordered" : "table"}
ariaLabel="Open compliance export actions"
trigger={dropdownTrigger}
>
<ActionDropdownItem
icon={
@@ -76,31 +76,31 @@ export const ComplianceHeader = ({
return (
<>
{hasContent && (
<div className="flex w-full items-start justify-between gap-6 sm:mb-8">
<div className="flex flex-1 flex-col justify-end gap-4">
{/* Showed in the details page */}
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
{/* Showed in the compliance page */}
{!hideFilters && (allFilters.length > 0 || showProviders) && (
<DataTableFilterCustom
filters={allFilters}
prependElement={prependElement}
/>
)}
</div>
{logoPath && complianceTitle && (
<div className="hidden shrink-0 sm:block">
<div className="relative h-24 w-24">
<Image
src={logoPath}
alt={`${complianceTitle} logo`}
fill
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
/>
</div>
<div className="flex w-full flex-col gap-4">
{/* Identity row: framework logo anchoring the scan context. */}
{(selectedScan || (logoPath && complianceTitle)) && (
<div className="flex w-full items-center gap-4">
{logoPath && complianceTitle && (
<div className="relative h-12 w-12 shrink-0">
<Image
src={logoPath}
alt={`${complianceTitle} logo`}
fill
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
/>
</div>
)}
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
</div>
)}
{/* Filters row */}
{!hideFilters && (allFilters.length > 0 || showProviders) && (
<DataTableFilterCustom
filters={allFilters}
prependElement={prependElement}
/>
)}
</div>
)}
</>
+1 -1
View File
@@ -111,7 +111,7 @@ export const Accordion = ({
<div
data-variant={variant}
className={cn(
"bg-bg-neutral-primary border-border-neutral-secondary w-full rounded-lg border px-2 py-1",
"bg-bg-input-primary dark:bg-input/30 border-border-neutral-secondary w-full rounded-lg border px-2 py-1",
className,
)}
>
+10 -2
View File
@@ -11,7 +11,7 @@ import {
import { cn } from "@/lib/utils";
const TRIGGER_STYLES = {
base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
active:
@@ -73,22 +73,30 @@ interface TabsTriggerProps
extends ComponentProps<typeof TabsPrimitive.Trigger> {
/** Tooltip shown below the trigger — useful when the label is truncated. */
tooltip?: ReactNode;
/** Content displayed next to the label without inheriting disabled opacity. */
adornment?: ReactNode;
}
function TabsTrigger({
className,
tooltip,
adornment,
children,
disabled,
...props
}: TabsTriggerProps) {
const trigger = (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(buildTriggerClassName(), className)}
disabled={disabled}
{...props}
>
{/* block + min-w-0 needed for truncate to render ellipsis */}
<span className="block min-w-0 truncate">{children}</span>
<span className={cn("block min-w-0 truncate", disabled && "opacity-50")}>
{children}
</span>
{adornment}
</TabsPrimitive.Trigger>
);
if (!tooltip) return trigger;
@@ -0,0 +1,31 @@
"use client";
import {
CROSS_PROVIDER_PDF_TASK_KIND,
crossProviderPdfHandler,
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
registerTaskKindHandler,
resumePendingTasks,
} from "@/store/task-watcher/store";
// Kind registrations happen at module scope, before any task can settle in
// this tab. Adding a new watched task kind (integration tests, scan exports,
// …) is one line here plus a handler next to the feature that owns it.
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
/**
* Mounted once in the app layout (next to `Toaster`): resumes polling any
* backend task persisted as pending by `@/store/task-watcher`, so completion
* toasts survive a hard reload. In-session tracking needs no component at
* all — `trackAndPollTask` runs its loop at module scope from the
* dispatching click handler.
*/
export const TaskPollingWatcher = () => {
useMountEffect(() => {
resumePendingTasks();
});
return null;
};
+7 -2
View File
@@ -188,9 +188,14 @@ export const downloadScanZip = async (
};
/**
* Generic function to download a file from base64 data
* Generic function to download a file from base64 data.
*
* Exported so feature-local wrappers (e.g. the cross-provider PDF download in
* app/(prowler)/compliance/_lib) reuse the base64→blob handling instead of
* duplicating it; those wrappers cannot live here because their server
* actions import the @/lib barrel, which would create a cycle.
*/
const downloadFile = async (
export const downloadFile = async (
result: ScanBinaryResult,
outputType: string,
successMessage: string,
+257
View File
@@ -0,0 +1,257 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { pollMock } = vi.hoisted(() => ({
pollMock: vi.fn(),
}));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollMock,
}));
import {
registerTaskKindHandler,
resumePendingTasks,
TASK_WATCHER_STATUS,
trackAndPollTask,
useTaskWatcherStore,
} from "./store";
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("task watcher store", () => {
const onReady = vi.fn();
const onError = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
Object.defineProperty(navigator, "locks", {
configurable: true,
value: undefined,
});
useTaskWatcherStore.setState({ tasks: {} });
registerTaskKindHandler("test-kind", { onReady, onError });
});
it("tracks a task, polls it to completion and fires onReady once", async () => {
pollMock.mockResolvedValue({ ok: true, state: "completed" });
await trackAndPollTask({
taskId: "task-1",
kind: "test-kind",
meta: { complianceId: "csa_ccm_4.0" },
});
const task = useTaskWatcherStore.getState().tasks["task-1"];
expect(task?.status).toBe(TASK_WATCHER_STATUS.READY);
expect(onReady).toHaveBeenCalledTimes(1);
expect(onReady.mock.calls[0][0].meta.complianceId).toBe("csa_ccm_4.0");
expect(onError).not.toHaveBeenCalled();
});
it("replaces settled results of the same kind when tracking new work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"previous-task": {
taskId: "previous-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.READY,
meta: {},
startedAt: Date.now() - 1000,
},
},
});
// When
await trackAndPollTask({
taskId: "replacement-task",
kind: "test-kind",
meta: {},
});
// Then
expect(
useTaskWatcherStore.getState().tasks["previous-task"],
).toBeUndefined();
expect(
useTaskWatcherStore.getState().tasks["replacement-task"]?.status,
).toBe(TASK_WATCHER_STATUS.READY);
});
it("marks the task as error when the backend task fails", async () => {
pollMock.mockResolvedValue({ ok: true, state: "failed" });
await trackAndPollTask({
taskId: "task-2",
kind: "test-kind",
meta: {},
});
expect(useTaskWatcherStore.getState().tasks["task-2"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
expect(onReady).not.toHaveBeenCalled();
});
it("settles the task as error when polling itself throws", async () => {
pollMock.mockRejectedValue(new Error("network down"));
await trackAndPollTask({ taskId: "task-7", kind: "test-kind", meta: {} });
expect(useTaskWatcherStore.getState().tasks["task-7"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
expect(onReady).not.toHaveBeenCalled();
});
it("keeps polling through transient timeouts until the ceiling", async () => {
pollMock
.mockResolvedValueOnce({ ok: false, error: "Task timeout" })
.mockResolvedValueOnce({ ok: true, state: "completed" });
await trackAndPollTask({ taskId: "task-3", kind: "test-kind", meta: {} });
expect(pollMock).toHaveBeenCalledTimes(2);
expect(useTaskWatcherStore.getState().tasks["task-3"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
});
it("does not start a second poll loop for an already-pending task", async () => {
let resolvePoll: (value: unknown) => void = () => {};
pollMock.mockImplementation(
() => new Promise((resolve) => (resolvePoll = resolve)),
);
const first = trackAndPollTask({
taskId: "task-4",
kind: "test-kind",
meta: {},
});
const second = trackAndPollTask({
taskId: "task-4",
kind: "test-kind",
meta: {},
});
await vi.waitFor(() => expect(pollMock).toHaveBeenCalledTimes(1));
resolvePoll({ ok: true, state: "completed" });
await Promise.all([first, second]);
await flush();
expect(pollMock).toHaveBeenCalledTimes(1);
expect(onReady).toHaveBeenCalledTimes(1);
});
it("does not poll or notify after another tab settles the same task", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"shared-task": {
taskId: "shared-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
const requestLock = vi.fn(
async (_name: string, callback: () => Promise<void>) => {
useTaskWatcherStore
.getState()
.resolveTask("shared-task", TASK_WATCHER_STATUS.READY);
await callback();
},
);
Object.defineProperty(navigator, "locks", {
configurable: true,
value: { request: requestLock },
});
// When
await resumePendingTasks();
// Then
expect(requestLock).toHaveBeenCalledTimes(1);
expect(pollMock).not.toHaveBeenCalled();
expect(onReady).not.toHaveBeenCalled();
});
it("resumes persisted pending tasks on watcher mount", async () => {
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"task-5": {
taskId: "task-5",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
await resumePendingTasks();
expect(useTaskWatcherStore.getState().tasks["task-5"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
expect(onReady).toHaveBeenCalledTimes(1);
});
it("discards settled tasks before resuming persisted work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
useTaskWatcherStore.setState({
tasks: {
"old-ready-task": {
taskId: "old-ready-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.READY,
meta: {},
startedAt: Date.now() - 1000,
},
"pending-task": {
taskId: "pending-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
// When
await resumePendingTasks();
// Then
expect(
useTaskWatcherStore.getState().tasks["old-ready-task"],
).toBeUndefined();
expect(useTaskWatcherStore.getState().tasks["pending-task"]?.status).toBe(
TASK_WATCHER_STATUS.READY,
);
});
it("expires stale persisted tasks instead of polling them", async () => {
useTaskWatcherStore.setState({
tasks: {
"task-6": {
taskId: "task-6",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now() - 60 * 60 * 1000,
},
},
});
await resumePendingTasks();
expect(pollMock).not.toHaveBeenCalled();
expect(useTaskWatcherStore.getState().tasks["task-6"]).toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { pollTaskUntilSettled } from "@/actions/task/poll";
// Generic background-task watcher: any feature that dispatches a backend
// task (report generation, integration tests, exports…) can track it here
// under a `kind` and get its completion handler fired even after the user
// navigates away. State persists to localStorage so a hard reload resumes
// in-flight tasks via `resumePendingTasks` (mounted once in the app layout
// through `TaskPollingWatcher`).
export const TASK_WATCHER_STATUS = {
PENDING: "pending",
READY: "ready",
ERROR: "error",
} as const;
export type TaskWatcherStatus =
(typeof TASK_WATCHER_STATUS)[keyof typeof TASK_WATCHER_STATUS];
export interface WatchedTask {
taskId: string;
kind: string;
status: TaskWatcherStatus;
/** Small serializable context the kind handler needs (ids, filenames…).
* Never store sensitive values: this persists to localStorage. */
meta: Record<string, string>;
startedAt: number;
error?: string;
}
export interface TaskKindHandler {
onReady: (task: WatchedTask) => void;
onError: (task: WatchedTask) => void;
}
interface TaskWatcherState {
tasks: Record<string, WatchedTask>;
upsertTask: (task: WatchedTask) => void;
resolveTask: (
taskId: string,
status: TaskWatcherStatus,
error?: string,
) => void;
dismissTask: (taskId: string) => void;
}
/** Tasks pending longer than this are considered lost on resume. */
const STALE_TASK_MS = 30 * 60 * 1000;
/** Poll rounds per task; each server round is itself 10 × 2s. */
const MAX_POLL_ROUNDS = 15;
const handlers = new Map<string, TaskKindHandler>();
/** Registers the completion handler for a task kind. Kinds are registered at
* module scope by `task-kind-registrations`, so they exist before any task
* can settle. */
export const registerTaskKindHandler = (
kind: string,
handler: TaskKindHandler,
): void => {
handlers.set(kind, handler);
};
export const useTaskWatcherStore = create<TaskWatcherState>()(
persist(
(set) => ({
tasks: {},
upsertTask: (task) =>
set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })),
resolveTask: (taskId, status, error) =>
set((state) => {
const task = state.tasks[taskId];
if (!task) return state;
return {
tasks: { ...state.tasks, [taskId]: { ...task, status, error } },
};
}),
dismissTask: (taskId) =>
set((state) => {
const { [taskId]: _dismissed, ...rest } = state.tasks;
return { tasks: rest };
}),
}),
{
name: "task-watcher",
partialize: (state) => ({ tasks: state.tasks }),
},
),
);
// In-memory only: poll loops alive in THIS tab. Never persisted, so a reload
// naturally re-enters through resumePendingTasks without double-polling.
const activePolls = new Set<string>();
const settleTask = (
taskId: string,
status: TaskWatcherStatus,
error?: string,
) => {
const store = useTaskWatcherStore.getState();
const currentTask = store.tasks[taskId];
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
return;
}
store.resolveTask(taskId, status, error);
const task = useTaskWatcherStore.getState().tasks[taskId];
if (!task) return;
const handler = handlers.get(task.kind);
if (handler) {
if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task);
else handler.onError(task);
}
// Error details have no durable consumer after the handler surfaces them.
// Ready tasks stay available so feature UI can expose their result.
if (status === TASK_WATCHER_STATUS.ERROR) {
store.dismissTask(taskId);
}
};
const runPollLoop = async (taskId: string): Promise<void> => {
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
const result = await pollTaskUntilSettled(taskId);
if (result.ok) {
if (result.state === "completed") {
settleTask(taskId, TASK_WATCHER_STATUS.READY);
} else {
settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
`Task ended in state "${result.state}".`,
);
}
return;
}
// "Task timeout" just means this server round expired while the task
// is still running — keep polling. Real errors settle immediately.
if (result.error !== "Task timeout") {
settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error);
return;
}
}
settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"The task is taking too long. Try again later.",
);
};
const pollUntilDone = async (taskId: string): Promise<void> => {
if (activePolls.has(taskId)) return;
activePolls.add(taskId);
try {
const runIfPending = async () => {
// A different tab may have completed the task while this one waited for
// the cross-tab lock. Refresh persisted state before polling or notifying.
await useTaskWatcherStore.persist.rehydrate();
const task = useTaskWatcherStore.getState().tasks[taskId];
if (task?.status !== TASK_WATCHER_STATUS.PENDING) return;
await runPollLoop(taskId);
};
if (typeof navigator !== "undefined" && navigator.locks) {
await navigator.locks.request(`task-watcher:${taskId}`, runIfPending);
} else {
await runIfPending();
}
} catch {
// A thrown poll (e.g. the server-action RPC failing on a network drop)
// must still settle the task, or it stays PENDING in the persisted
// store and blocks the UI until the staleness ceiling.
settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"Tracking the task failed unexpectedly. Try again later.",
);
} finally {
activePolls.delete(taskId);
}
};
/** Track a freshly dispatched backend task and poll it to completion. The
* poll loop lives at module scope (fired from the click handler), so it
* survives client-side navigation without any effect subscriptions. */
export const trackAndPollTask = async ({
taskId,
kind,
meta,
}: {
taskId: string;
kind: string;
meta: Record<string, string>;
}): Promise<void> => {
const existing = useTaskWatcherStore.getState().tasks[taskId];
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
return pollUntilDone(taskId);
}
const store = useTaskWatcherStore.getState();
Object.values(store.tasks)
.filter(
(task) =>
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
)
.forEach((task) => store.dismissTask(task.taskId));
store.upsertTask({
taskId,
kind,
status: TASK_WATCHER_STATUS.PENDING,
meta,
startedAt: Date.now(),
});
return pollUntilDone(taskId);
};
/** Resume polling every persisted pending task after a hard reload; tasks
* pending longer than the staleness ceiling are settled as errors without
* hitting the API. Called once on app mount by `TaskPollingWatcher`. */
export const resumePendingTasks = async (): Promise<void> => {
const store = useTaskWatcherStore.getState();
const persistedTasks = Object.values(store.tasks);
const pending = persistedTasks.filter(
(task) => task.status === TASK_WATCHER_STATUS.PENDING,
);
// Settled entries already surfaced in the previous browser session. The
// server-rendered feature UI resolves durable results again on reload, so
// keeping these records would only grow localStorage forever.
persistedTasks
.filter((task) => task.status !== TASK_WATCHER_STATUS.PENDING)
.forEach((task) => store.dismissTask(task.taskId));
await Promise.all(
pending.map((task) => {
if (Date.now() - task.startedAt > STALE_TASK_MS) {
settleTask(
task.taskId,
TASK_WATCHER_STATUS.ERROR,
"The task expired before it could be tracked to completion.",
);
return Promise.resolve();
}
return pollUntilDone(task.taskId);
}),
);
};
+6
View File
@@ -1,3 +1,5 @@
import type { ProviderType } from "./providers";
export const REQUIREMENT_STATUS = {
PASS: "PASS",
FAIL: "FAIL",
@@ -48,6 +50,10 @@ export interface Requirement {
[key: string]: string | string[] | number | boolean | object[] | undefined;
}
/** Check id → provider types it belongs to, for provider-labeled check
* lists in the cross-provider view (a check can exist in several). */
export type CheckProviderTypesMap = Partial<Record<string, ProviderType[]>>;
export interface Control {
label: string;
pass: number;