mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-18 01:51:50 +00:00
feat(ui): add cross-provider compliance view (#11912)
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user