feat(ui): add cross-account compliance view (#12086)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-07-28 14:03:58 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 1460f7b188
commit 0d4a21b5a4
80 changed files with 4571 additions and 822 deletions
@@ -3,6 +3,8 @@
import Image, { type StaticImageData } from "next/image";
import { useState } from "react";
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { SortToggleButton } from "./sort-toggle-button";
import { WatchlistCard } from "./watchlist-card";
@@ -47,7 +49,7 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
title="Compliance Watchlist"
items={sortedItems}
ctaLabel="Explore Compliance for Each Scan"
ctaHref="/compliance"
ctaHref={buildPerScanComplianceHref()}
headerAction={
<SortToggleButton
isAscending={isAsc}
@@ -42,8 +42,10 @@ import {
Framework,
RequirementsTotals,
} from "@/types/compliance";
import { isKnownProviderType } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { CrossAccountDetail } from "../_components/cross-account-detail";
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks";
import { buildSearchParamsKey } from "../_lib/search-params-key";
@@ -113,6 +115,51 @@ export default async function ComplianceDetail({
</ContentLayout>
);
}
// Cross-account mode: one regular framework aggregated across every
// account of one provider type. Cloud-only, like cross-provider.
if (mode === "cross-account") {
if (!isCloud()) {
redirect("/compliance");
}
const providerType = getSingleSearchParam(
resolvedSearchParams.providerType,
);
if (!providerType || !isKnownProviderType(providerType)) {
notFound();
}
const crossAccountTitle = compliancetitle.split("-").join(" ");
return (
<ContentLayout
title={
version ? `${crossAccountTitle} - ${version}` : crossAccountTitle
}
>
<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>
}
>
<CrossAccountDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
providerType={providerType}
searchParams={resolvedSearchParams}
targetSection={section}
/>
</Suspense>
</ContentLayout>
);
}
const regionFilter = getSingleSearchParam(
resolvedSearchParams["filter[region__in]"],
);
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
captureExceptionMock,
fetchMock,
getAuthHeadersMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
}));
import {
generateCrossAccountPdf,
getCrossAccountComplianceOverview,
getCrossAccountPdfBinary,
getLatestCrossAccountPdf,
} from "./cross-account";
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/vnd.api+json" },
});
const lastFetchUrl = () => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
return new URL(String(call[0]));
};
const fetchCallAt = (index: number) => {
const call = fetchMock.mock.calls[index];
if (!call) throw new Error(`fetch call ${index} was not found`);
return {
init: call[1] as RequestInit,
url: new URL(String(call[0])),
};
};
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(jsonResponse({ data: null }));
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer test-token" });
handleApiResponseMock.mockResolvedValue({ data: null });
});
afterEach(() => {
vi.useRealTimers();
});
describe("cross-account compliance actions", () => {
it("loads the overview with its identity and account filters", async () => {
const payload = { data: { id: "cis_2.0_aws" } };
handleApiResponseMock.mockResolvedValue(payload);
const result = await getCrossAccountComplianceOverview({
complianceId: "cis_2.0_aws",
providerType: "aws",
filters: {
scanIds: ["scan-1", "scan-2"],
providerIds: "provider-1,provider-2",
providerGroups: "group-1",
},
});
expect(result).toEqual({ status: "success", response: payload });
const url = lastFetchUrl();
expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews");
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
expect(url.searchParams.get("filter[provider_type]")).toBe("aws");
expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2");
expect(url.searchParams.get("filter[provider_id__in]")).toBe(
"provider-1,provider-2",
);
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
});
it("routes PDF operations through the cross-account endpoints", async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202),
)
.mockResolvedValueOnce(
new Response(Buffer.from("pdf-bytes"), {
headers: {
"Content-Disposition": 'attachment; filename="report.pdf"',
"Content-Type": "application/pdf",
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
data: {
id: "task-2",
attributes: { result: { filename: "latest.pdf" } },
},
}),
);
await generateCrossAccountPdf({
complianceId: "cis_2.0_aws",
providerType: "aws",
filters: { scanIds: ["scan-1"] },
reportName: "report.pdf",
});
await getCrossAccountPdfBinary("task-1");
await getLatestCrossAccountPdf({
complianceId: "cis_2.0_aws",
providerType: "aws",
filters: { providerIds: "provider-1" },
});
const generation = fetchCallAt(0);
expect(generation.init.method).toBe("POST");
expect(generation.url.pathname).toBe(
"/api/v1/cross-account-compliance-overviews/pdf",
);
expect(generation.url.searchParams.get("filter[compliance_id]")).toBe(
"cis_2.0_aws",
);
expect(generation.url.searchParams.get("filter[provider_type]")).toBe(
"aws",
);
expect(generation.url.searchParams.get("filter[scan__in]")).toBe("scan-1");
expect(generation.url.searchParams.get("report_name")).toBe("report.pdf");
const binary = fetchCallAt(1);
expect(binary.url.pathname).toBe(
"/api/v1/cross-account-compliance-overviews/pdf/task-1",
);
const latest = fetchCallAt(2);
expect(latest.url.pathname).toBe(
"/api/v1/cross-account-compliance-overviews/pdf/latest",
);
expect(latest.url.searchParams.get("filter[provider_id__in]")).toBe(
"provider-1",
);
expect([generation, binary, latest].every(({ init }) => init.signal)).toBe(
true,
);
});
it("aborts a stalled request and reports the network failure", async () => {
vi.useFakeTimers();
let requestSignal: AbortSignal | undefined;
fetchMock.mockImplementation(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise((_resolve, reject) => {
requestSignal = init?.signal ?? undefined;
requestSignal?.addEventListener("abort", () => {
reject(requestSignal?.reason ?? new Error("aborted"));
});
}),
);
const request = getCrossAccountComplianceOverview({
complianceId: "cis_2.0_aws",
providerType: "aws",
});
await vi.advanceTimersByTimeAsync(30_000);
await expect(request).resolves.toEqual({
status: "load-error",
message:
"Could not load cross-provider compliance data. Try again later.",
});
expect(requestSignal?.aborted).toBe(true);
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,126 @@
"use server";
import type { ScanBinaryResult } from "@/actions/scans/scans";
import { apiBaseUrl } from "@/lib";
import {
generateAggregatedCompliancePdf,
getAggregatedComplianceOverview,
getAggregatedCompliancePdfBinary,
getLatestAggregatedCompliancePdf,
} from "../_lib/aggregated-compliance-actions";
import type {
CrossAccountApiFilters,
CrossAccountOverviewResponse,
CrossAccountOverviewResult,
LatestCrossProviderPdf,
} from "../_types";
const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews";
const applyCrossAccountParams = (
url: URL,
complianceId: string,
providerType: string,
filters?: CrossAccountApiFilters,
) => {
url.searchParams.set("filter[compliance_id]", complianceId);
url.searchParams.set("filter[provider_type]", providerType);
if (filters?.scanIds?.length) {
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
}
const params = {
"filter[provider_id__in]": filters?.providerIds,
"filter[provider_groups__in]": filters?.providerGroups,
};
for (const [key, value] of Object.entries(params)) {
if (value?.trim()) url.searchParams.set(key, value);
}
};
const buildCrossAccountUrl = (
suffix: string,
complianceId: string,
providerType: string,
filters?: CrossAccountApiFilters,
) => {
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}${suffix}`);
applyCrossAccountParams(url, complianceId, providerType, filters);
return url;
};
export const getCrossAccountComplianceOverview = async ({
complianceId,
providerType,
filters,
}: {
complianceId: string;
providerType: string;
filters?: CrossAccountApiFilters;
}): Promise<CrossAccountOverviewResult> => {
const url = buildCrossAccountUrl("", complianceId, providerType, filters);
return getAggregatedComplianceOverview<CrossAccountOverviewResponse>(
url,
`GET ${CROSS_ACCOUNT_API_PATH}`,
);
};
export const generateCrossAccountPdf = async ({
complianceId,
providerType,
filters,
reportName,
}: {
complianceId: string;
providerType: string;
filters?: CrossAccountApiFilters;
reportName?: string;
}): Promise<{ taskId: string } | { error: string }> => {
const url = buildCrossAccountUrl("/pdf", complianceId, providerType, filters);
if (reportName) url.searchParams.set("report_name", reportName);
return generateAggregatedCompliancePdf(
url,
`POST ${CROSS_ACCOUNT_API_PATH}/pdf`,
);
};
export const getCrossAccountPdfBinary = async (
taskId: string,
): Promise<ScanBinaryResult> => {
const safeTaskId = taskId.trim();
if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) {
return { error: "Invalid task identifier." };
}
const url = new URL(
`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`,
);
return getAggregatedCompliancePdfBinary({
url,
operation: `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`,
defaultFilename: "cross-account-compliance.pdf",
});
};
export const getLatestCrossAccountPdf = async ({
complianceId,
providerType,
filters,
}: {
complianceId: string;
providerType: string;
filters?: CrossAccountApiFilters;
}): Promise<LatestCrossProviderPdf | null> => {
const url = buildCrossAccountUrl(
"/pdf/latest",
complianceId,
providerType,
filters,
);
return getLatestAggregatedCompliancePdf(
url,
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`,
);
};
@@ -1,119 +1,54 @@
"use server";
import * as Sentry from "@sentry/nextjs";
import type { ScanBinaryResult } from "@/actions/scans/scans";
import {
apiBaseUrl,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import { hasActionError } from "@/lib/action-errors";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import { apiBaseUrl } from "@/lib";
import {
generateAggregatedCompliancePdf,
getAggregatedComplianceOverview,
getAggregatedCompliancePdfBinary,
getLatestAggregatedCompliancePdf,
} from "../_lib/aggregated-compliance-actions";
import type {
CrossProviderApiFilters,
CrossProviderOverviewResponse,
CrossProviderOverviewResult,
LatestCrossProviderPdf,
} from "../_types";
import {
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
} from "../_types";
const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews";
/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */
interface PdfEndpointErrorBody {
errors?: Array<{ detail?: string }>;
error?: string;
message?: string;
}
const applyCrossProviderParams = (
url: URL,
complianceId: string,
filters?: CrossProviderApiFilters,
) => {
url.searchParams.set("filter[compliance_id]", complianceId);
/**
* Extracts a user-safe message from a failed PDF endpoint response and, for
* unexpected failures, reports it to Sentry. `operation` must be a STATIC
* route template (e.g. `GET .../pdf/{taskId}`) — never the
* resolved URL, which would carry the task id or a user-typed report name.
*/
const getPdfEndpointErrorMessage = async (
response: Response,
fallbackMessage: string,
operation: string,
): Promise<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) {
if (filters?.scanIds?.length) {
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
}
const paramMap = {
"filter[provider_type__in]": filters.providerTypes,
"filter[provider_id__in]": filters.providerIds,
"filter[provider_groups__in]": filters.providerGroups,
const params = {
"filter[provider_type__in]": filters?.providerTypes,
"filter[provider_id__in]": filters?.providerIds,
"filter[provider_groups__in]": filters?.providerGroups,
};
for (const [key, value] of Object.entries(paramMap)) {
if (value && value.trim().length > 0) {
url.searchParams.set(key, value);
}
for (const [key, value] of Object.entries(params)) {
if (value?.trim()) url.searchParams.set(key, value);
}
};
/**
* Aggregate a universal compliance framework across one scan per compatible
* provider (Prowler Cloud only — the OSS API has no such endpoint).
*
* When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED
* scan per compatible provider. Non-2xx responses are returned as structured
* action errors so callers can reuse the app-wide 402/403 handlers.
*/
const buildCrossProviderUrl = (
suffix: string,
complianceId: string,
filters?: CrossProviderApiFilters,
) => {
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}${suffix}`);
applyCrossProviderParams(url, complianceId, filters);
return url;
};
export const getCrossProviderComplianceOverview = async ({
complianceId,
filters,
@@ -121,45 +56,13 @@ export const getCrossProviderComplianceOverview = async ({
complianceId: string;
filters?: CrossProviderApiFilters;
}): Promise<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,
};
}
const url = buildCrossProviderUrl("", complianceId, filters);
return getAggregatedComplianceOverview<CrossProviderOverviewResponse>(
url,
`GET ${CROSS_PROVIDER_API_PATH}`,
);
};
/**
* Trigger ad-hoc generation of the combined cross-provider compliance PDF.
*
* The PDF is built asynchronously by a backend task: this returns the task id
* so the caller can poll it and then download via
* {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on
* screen (`attributes.scan_ids`) so the report matches the displayed data
* instead of re-resolving "latest scan per provider", which could race a scan
* completing in between.
*/
export const generateCrossProviderPdf = async ({
complianceId,
filters,
@@ -167,109 +70,34 @@ export const generateCrossProviderPdf = async ({
}: {
complianceId: string;
filters?: CrossProviderApiFilters;
/** Optional download filename; sanitized server-side. */
reportName?: string;
}): Promise<{ taskId: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf`);
url.searchParams.set("filter[compliance_id]", complianceId);
applyFilters(url, filters);
const url = buildCrossProviderUrl("/pdf", complianceId, filters);
if (reportName) url.searchParams.set("report_name", reportName);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to start PDF generation. Contact support if the issue continues.",
`POST ${CROSS_PROVIDER_API_PATH}/pdf`,
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) {
throw new Error("Unexpected response starting PDF generation.");
}
return { taskId };
} catch (error) {
return { error: getErrorMessage(error) };
}
return generateAggregatedCompliancePdf(
url,
`POST ${CROSS_PROVIDER_API_PATH}/pdf`,
);
};
/**
* Fetch the finished cross-provider PDF for a task started by
* {@link generateCrossProviderPdf}. Speaks the same 202-pending /
* 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it
* returns the shared {@link ScanBinaryResult} shape and callers can reuse the
* existing download plumbing unchanged.
*/
export const getCrossProviderPdfBinary = async (
taskId: string,
): Promise<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) };
}
return getAggregatedCompliancePdfBinary({
url,
operation: `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`,
defaultFilename: "cross-provider-compliance.pdf",
});
};
/**
* Check whether a cross-provider PDF already exists for the given filters so
* the UI can offer "Download" immediately instead of forcing a re-generate.
*
* 404 means "not generated yet" — a normal state, returned as `null` rather
* than an error. The backend only matches reports built from the exact scan
* set the filters resolve to, so a report goes stale (→ `null`) as soon as a
* contributing provider completes a new scan. Failures also degrade to
* `null`: this is an optional availability check and the caller's fallback
* (show "Generate") is always safe.
*/
export const getLatestCrossProviderPdf = async ({
complianceId,
filters,
@@ -277,39 +105,9 @@ export const getLatestCrossProviderPdf = async ({
complianceId: string;
filters?: CrossProviderApiFilters;
}): Promise<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;
}
const url = buildCrossProviderUrl("/pdf/latest", complianceId, filters);
return getLatestAggregatedCompliancePdf(
url,
`GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`,
);
};
@@ -0,0 +1,141 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
vi.mock("@/components/compliance", () => ({
ClientAccordionWrapper: () => <div />,
RequirementsStatusCard: () => <div />,
TopFailedSectionsCard: () => <div />,
}));
describe("AggregatedComplianceDetail", () => {
it("stacks actions on mobile and keeps the link after the title on desktop", () => {
// Given / When
const { container } = render(
<AggregatedComplianceDetail
compliancetitle="CSA-CCM"
logoPath="/csa.svg"
title={<span>CSA Cloud Controls Matrix</span>}
description={<p>5 of 5 compatible providers scanned</p>}
headerLink={<a href="https://hub.prowler.com">View on Prowler Hub</a>}
reportAction={<button type="button">Report</button>}
filters={<div>Filters</div>}
totals={{ pass: 1, fail: 2, manual: 3 }}
coverage={<div>Coverage</div>}
topFailed={{ sections: [], dataType: "sections" }}
accordionItems={[]}
initialExpandedKeys={[]}
/>,
);
// Then
const logo = screen.getByAltText("CSA-CCM logo");
const header = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-header"]',
);
const heading = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-heading"]',
);
const title = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-title"]',
);
const description = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-description"]',
);
const headerLink = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-header-link"]',
);
const reportAction = container.querySelector<HTMLElement>(
'[data-slot="aggregated-compliance-report-action"]',
);
if (
!header ||
!heading ||
!title ||
!description ||
!headerLink ||
!reportAction
) {
throw new Error("Expected every aggregated compliance header region");
}
expect(header).toHaveClass("sm:grid-cols-[auto_minmax(0,1fr)_auto]");
expect(heading).toHaveClass(
"contents",
"sm:grid",
"sm:grid-cols-[minmax(0,max-content)_auto]",
"sm:items-center",
"sm:justify-start",
"sm:gap-x-4",
);
expect(heading).toContainElement(title);
expect(heading).toContainElement(description);
expect(heading).toContainElement(headerLink);
expect(title).toHaveClass(
"col-start-2",
"row-start-1",
"min-w-0",
"truncate",
);
expect(description).toHaveClass("col-span-2", "row-start-2");
expect(headerLink).toHaveClass("col-span-2", "row-start-3");
expect(reportAction).toHaveClass(
"col-span-2",
"row-start-4",
"sm:col-start-3",
);
const orderedElements = [
logo,
title,
description,
headerLink,
reportAction,
];
orderedElements.slice(0, -1).forEach((element, index) => {
expect(
element.compareDocumentPosition(orderedElements[index + 1]!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
it("places the report directly after information when no header link exists", () => {
// Given / When
const { container } = render(
<AggregatedComplianceDetail
compliancetitle="Custom"
title={<span>Custom Framework</span>}
description={<p>2 accounts aggregated</p>}
reportAction={<button type="button">Report</button>}
filters={<div>Filters</div>}
totals={{ pass: 1, fail: 2, manual: 3 }}
coverage={<div>Coverage</div>}
topFailed={{ sections: [], dataType: "sections" }}
accordionItems={[]}
initialExpandedKeys={[]}
/>,
);
// Then
expect(
container.querySelector('[data-slot="aggregated-compliance-title"]'),
).toHaveClass("col-start-1", "row-start-1");
expect(
container.querySelector(
'[data-slot="aggregated-compliance-description"]',
),
).toHaveClass("col-start-1", "row-start-2");
expect(
container.querySelector(
'[data-slot="aggregated-compliance-report-action"]',
),
).toHaveClass("col-start-1", "row-start-3");
expect(
container.querySelector(
'[data-slot="aggregated-compliance-header-link"]',
),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,160 @@
import Image from "next/image";
import type { ComponentProps, ReactNode } from "react";
import {
ClientAccordionWrapper,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { Card } from "@/components/shadcn/card/card";
import { cn } from "@/lib/utils";
import type { RequirementsTotals } from "@/types/compliance";
interface AggregatedComplianceDetailProps {
compliancetitle: string;
logoPath?: ComponentProps<typeof Image>["src"];
title: ReactNode;
description: ReactNode;
headerLink?: ReactNode;
reportAction: ReactNode;
filters: ReactNode;
totals: RequirementsTotals;
coverage: ReactNode;
topFailed: ComponentProps<typeof TopFailedSectionsCard>;
accordionItems: AccordionItemProps[];
initialExpandedKeys: string[];
}
export const AggregatedComplianceDetail = ({
compliancetitle,
logoPath,
title,
description,
headerLink,
reportAction,
filters,
totals,
coverage,
topFailed,
accordionItems,
initialExpandedKeys,
}: AggregatedComplianceDetailProps) => (
<div className="flex flex-col gap-8">
<Card variant="base" padding="lg">
<div className="flex w-full flex-col gap-4">
<div
data-slot="aggregated-compliance-header"
className={cn(
"grid w-full items-center gap-x-4 gap-y-3",
logoPath
? "grid-cols-[auto_minmax(0,1fr)] sm:grid-cols-[auto_minmax(0,1fr)_auto]"
: "grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto]",
)}
>
{logoPath && (
<div className="relative col-start-1 row-start-1 h-12 w-12 shrink-0 sm:row-span-2">
<Image
src={logoPath}
alt={`${compliancetitle} logo`}
fill
sizes="48px"
className="border-border-neutral-tertiary rounded-lg border bg-slate-50 object-contain"
/>
</div>
)}
{headerLink ? (
<div
data-slot="aggregated-compliance-heading"
className={cn(
"contents sm:row-span-2 sm:row-start-1 sm:grid sm:min-w-0 sm:grid-cols-[minmax(0,max-content)_auto] sm:items-center sm:justify-start sm:gap-x-4 sm:gap-y-3",
logoPath ? "sm:col-start-2" : "sm:col-start-1",
)}
>
<div
data-slot="aggregated-compliance-title"
className={cn(
"row-start-1 min-w-0 truncate",
logoPath ? "col-start-2" : "col-start-1",
"sm:col-start-1 sm:row-start-1",
)}
>
{title}
</div>
<div
data-slot="aggregated-compliance-description"
className={cn(
"row-start-2 min-w-0 sm:col-span-2 sm:col-start-1 sm:row-start-2",
logoPath ? "col-span-2 col-start-1" : "col-start-1",
)}
>
{description}
</div>
<div
data-slot="aggregated-compliance-header-link"
className={cn(
"row-start-3 justify-self-start sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:shrink-0",
logoPath ? "col-span-2 col-start-1" : "col-start-1",
)}
>
{headerLink}
</div>
</div>
) : (
<>
<div
data-slot="aggregated-compliance-title"
className={cn(
"row-start-1 min-w-0 truncate",
logoPath ? "col-start-2" : "col-start-1",
)}
>
{title}
</div>
<div
data-slot="aggregated-compliance-description"
className={cn(
"row-start-2 min-w-0",
logoPath
? "col-span-2 col-start-1 sm:col-span-1 sm:col-start-2"
: "col-start-1",
)}
>
{description}
</div>
</>
)}
<div
data-slot="aggregated-compliance-report-action"
className={cn(
"justify-self-start sm:row-span-2 sm:row-start-1 sm:justify-self-end",
headerLink ? "row-start-4" : "row-start-3",
logoPath
? "col-span-2 col-start-1 sm:col-span-1 sm:col-start-3"
: "col-start-1 sm:col-start-2",
)}
>
{reportAction}
</div>
</div>
{filters}
</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}
/>
{coverage}
<TopFailedSectionsCard {...topFailed} />
</div>
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys.at(-1)}
/>
</div>
);
@@ -0,0 +1,85 @@
import Image from "next/image";
import type { KeyboardEventHandler, ReactNode } from "react";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Card, CardContent } from "@/components/shadcn/card/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
interface AggregatedFrameworkCardProps {
frameworkTitle: string;
formattedTitle: string;
ariaLabel: string;
onActivate: () => void;
subtitle: ReactNode;
tooltip?: string;
children: ReactNode;
}
export const AggregatedFrameworkCard = ({
frameworkTitle,
formattedTitle,
ariaLabel,
onActivate,
subtitle,
tooltip,
children,
}: AggregatedFrameworkCardProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onActivate();
}
};
const logo = getComplianceIcon(frameworkTitle);
const title = (
<h4 className="truncate text-sm leading-5 font-bold">{formattedTitle}</h4>
);
return (
<Card
variant="base"
padding="md"
interactive
onClick={onActivate}
role="button"
aria-label={ariaLabel}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<CardContent>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-3">
{logo && (
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
<Image
src={logo}
alt={`${frameworkTitle} logo`}
width={32}
height={32}
sizes="32px"
className="h-8 w-8 object-contain"
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>{title}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : (
title
)}
{subtitle}
</div>
</div>
{children}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,32 @@
"use client";
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
interface AggregatedRequirementContentProps {
requirement: Requirement;
framework: string;
scanIds: string[];
emptyMessage: string;
checkProviders?: CheckProviderTypesMap;
}
export const AggregatedRequirementContent = ({
requirement,
framework,
scanIds,
emptyMessage,
checkProviders,
}: AggregatedRequirementContentProps) => {
if (scanIds.length === 0) return <p className="text-sm">{emptyMessage}</p>;
return (
<ClientAccordionContent
requirement={requirement}
scanIds={scanIds}
framework={framework}
checkProviders={checkProviders}
disableFindings={requirement.check_ids.length === 0}
/>
);
};
@@ -1,17 +1,26 @@
import { COMPLIANCE_TAB, type ComplianceTab } from "../_types";
import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance";
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;
/** Resolves `?tab=` into a valid tab, defaulting to Cross Provider — the
* Multiple Scans tab, which owns the bare `/compliance` route.
*
* A `scanId` with no explicit tab means the link predates the tab split (or
* was shared from Single Scan), so it keeps resolving to Per Scan instead of
* landing on the aggregated view, which ignores the scan entirely. */
function getComplianceTab(
value: string | string[] | undefined,
scanId?: string | string[] | undefined,
): ComplianceTab {
if (typeof value === "string" && isComplianceTab(value)) {
return value;
}
return isComplianceTab(value) ? value : COMPLIANCE_TAB.PER_SCAN;
return typeof scanId === "string" && scanId
? COMPLIANCE_TAB.PER_SCAN
: COMPLIANCE_TAB.CROSS_PROVIDER;
}
export { getComplianceTab };
@@ -4,8 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { COMPLIANCE_TAB } from "../_types";
import { COMPLIANCE_TAB } from "@/types/compliance";
import { CompliancePageTabs } from "./compliance-page-tabs";
import { getComplianceTab } from "./compliance-page-tabs.shared";
@@ -21,14 +20,29 @@ vi.mock("next/navigation", () => ({
}));
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);
it("falls back to cross-provider for missing or invalid values", () => {
expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.CROSS_PROVIDER);
expect(getComplianceTab(["per-scan"])).toBe(COMPLIANCE_TAB.CROSS_PROVIDER);
expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER);
expect(getComplianceTab("per-scan")).toBe(COMPLIANCE_TAB.PER_SCAN);
expect(getComplianceTab("cross-provider")).toBe(
COMPLIANCE_TAB.CROSS_PROVIDER,
);
});
it("keeps pre-split links alive: a bare scanId still opens Single Scan", () => {
expect(getComplianceTab(undefined, "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN);
expect(getComplianceTab("bogus", "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN);
// An explicit tab always wins over the inferred one.
expect(getComplianceTab("cross-provider", "scan-1")).toBe(
COMPLIANCE_TAB.CROSS_PROVIDER,
);
// Empty or repeated scanId carries no selection to honour.
expect(getComplianceTab(undefined, "")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER);
expect(getComplianceTab(undefined, ["scan-1"])).toBe(
COMPLIANCE_TAB.CROSS_PROVIDER,
);
});
});
describe("CompliancePageTabs", () => {
@@ -40,21 +54,8 @@ describe("CompliancePageTabs", () => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
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(
it("renders Multiple Scans as the first tab", () => {
render(
<CompliancePageTabs
activeTab={COMPLIANCE_TAB.CROSS_PROVIDER}
crossProviderEnabled
@@ -63,7 +64,35 @@ describe("CompliancePageTabs", () => {
/>,
);
await user.click(screen.getByRole("tab", { name: /per scan/i }));
expect(
screen.getAllByRole("tab").map((tab) => tab.textContent),
).toStrictEqual(["Multiple Scans", "Single Scan"]);
});
it("navigates with ?tab=per-scan and back to the bare route", async () => {
const user = userEvent.setup();
const { rerender } = render(
<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: /single scan/i }));
expect(pushMock).toHaveBeenCalledWith("/compliance?tab=per-scan");
rerender(
<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: /multiple scans/i }));
expect(pushMock).toHaveBeenCalledWith("/compliance");
});
@@ -79,7 +108,7 @@ describe("CompliancePageTabs", () => {
);
const crossProviderTab = screen.getByRole("tab", {
name: /cross-provider/i,
name: /multiple scans/i,
});
await user.click(crossProviderTab);
@@ -10,10 +10,10 @@ import {
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { COMPLIANCE_TAB, type ComplianceTab } from "../_types";
import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance";
interface CompliancePageTabsProps {
activeTab: ComplianceTab;
@@ -47,42 +47,43 @@ export const CompliancePageTabs = ({
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}`);
}
// Multiple Scans is the landing view, so it owns the bare route; Single
// Scan pins `?tab=` to stay linkable.
router.push(
typedTab === COMPLIANCE_TAB.CROSS_PROVIDER
? "/compliance"
: buildPerScanComplianceHref(),
);
};
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}
adornment={
!crossProviderEnabled ? (
<Badge variant="cloud">Cloud</Badge>
) : undefined
}
>
Cross-Provider
</TabsTrigger>
</TabsList>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<div className="flex flex-col gap-[18px]">
<div data-tour-id="view-compliance-tabs" className="overflow-x-auto">
<TabsList>
<TabsTrigger
value={COMPLIANCE_TAB.CROSS_PROVIDER}
adornment={
!crossProviderEnabled ? (
<Badge variant="cloud">Cloud</Badge>
) : undefined
}
>
Multiple Scans
</TabsTrigger>
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>
Single Scan
</TabsTrigger>
</TabsList>
</div>
<TabsContent value={COMPLIANCE_TAB.PER_SCAN}>
{perScanContent}
</TabsContent>
<TabsContent value={COMPLIANCE_TAB.CROSS_PROVIDER}>
{crossProviderContent}
</TabsContent>
<TabsContent value={COMPLIANCE_TAB.CROSS_PROVIDER}>
{crossProviderContent}
</TabsContent>
<TabsContent value={COMPLIANCE_TAB.PER_SCAN}>
{perScanContent}
</TabsContent>
</div>
</Tabs>
);
};
@@ -0,0 +1,214 @@
import { Info } from "lucide-react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
type KnownProviderType,
PROVIDER_DISPLAY_NAMES,
} from "@/types/providers";
import {
getCrossAccountComplianceOverview,
getLatestCrossAccountPdf,
} from "../_actions/cross-account";
import {
getAggregatedInitialExpandedKeys,
getAggregatedRequirementsTotals,
} from "../_lib/aggregated-compliance-detail";
import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion";
import {
buildAccountExtrasMap,
computeAccountBreakdown,
crossAccountToMapperInput,
} from "../_lib/cross-account-adapter";
import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
CrossProviderGroupOption,
} from "./cross-provider-filters";
import { CrossProviderFilters } from "./cross-provider-filters";
import { CrossProviderPdfButton } from "./cross-provider-pdf-button";
import type { CoverageRow } from "./provider-coverage-card";
import { ProviderCoverageCard } from "./provider-coverage-card";
interface CrossAccountDetailProps {
compliancetitle: string;
complianceId: string;
providerType: KnownProviderType;
searchParams: Record<string, string | string[] | undefined>;
targetSection?: string;
}
/**
* Server island for the cross-account detail (`?mode=cross-account`): the
* account-axis sibling of `CrossProviderDetail`. Fetches the roll-up of one
* regular framework across every account of one provider type, funnels it
* through the real framework mapper via the adapter, and renders the same
* summary-charts + accordion layout with per-account augmentations.
*/
export const CrossAccountDetail = async ({
compliancetitle,
complianceId,
providerType,
searchParams,
targetSection,
}: CrossAccountDetailProps) => {
const filters = parseCrossAccountFilters(searchParams);
const [overviewResponse, providersData, providerGroupsData] =
await Promise.all([
getCrossAccountComplianceOverview({
complianceId,
providerType,
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-account compliance data was returned for this framework. The
view aggregates the latest completed scan of every account of this
provider type — run a scan to populate it.
</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 an account finished a new scan between the two calls.
const latestPdf = await getLatestCrossAccountPdf({
complianceId,
providerType,
filters: { ...filters, scanIds: attrs.scan_ids },
});
const mapper = getComplianceMapper(attrs.framework);
const { attributesData, requirementsData } = crossAccountToMapperInput(attrs);
const data = mapper.mapComplianceData(attributesData, requirementsData);
const extras = buildAccountExtrasMap(attrs);
const coverageRows: CoverageRow[] = computeAccountBreakdown(attrs).map(
(entry) => ({
key: entry.id,
label: entry.label,
iconType: providerType,
pass: entry.pass,
fail: entry.fail,
manual: entry.manual,
score: entry.score,
}),
);
const totals = getAggregatedRequirementsTotals(data);
const accordionItems = toCrossAccountAccordionItems(
data,
extras,
attrs.framework,
attrs.accounts,
);
const topFailedResult = mapper.getTopFailedSections(data);
const initialExpandedKeys = getAggregatedInitialExpandedKeys(
data,
accordionItems,
targetSection,
);
const logoPath = getComplianceIcon(compliancetitle);
const providerAccounts: CrossProviderAccountOption[] = (
providersData?.data || []
)
.filter((provider) => provider.attributes.provider === providerType)
.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 (
<AggregatedComplianceDetail
compliancetitle={compliancetitle}
logoPath={logoPath}
title={
<span className="truncate text-sm font-medium">
{attrs.name || compliancetitle.split("-").join(" ")}
</span>
}
description={
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
<ProviderTypeIcon type={providerType} size={14} />
{PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "}
{attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "}
{attrs.scan_ids.length}{" "}
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
</p>
}
reportAction={
<CrossProviderPdfButton
complianceId={complianceId}
providerType={providerType}
filters={{ ...filters, scanIds: attrs.scan_ids }}
latestPdf={latestPdf}
/>
}
filters={
<CrossProviderFilters
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
}
totals={totals}
coverage={
<ProviderCoverageCard
rows={coverageRows}
title="Account Coverage"
emptyMessage="No scanned accounts for this framework yet."
/>
}
topFailed={{
sections: topFailedResult.items,
dataType: topFailedResult.type,
prepopulated: topFailedResult.prepopulated,
}}
accordionItems={accordionItems}
initialExpandedKeys={initialExpandedKeys}
/>
);
};
@@ -0,0 +1,64 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks";
import type { CrossAccountFrameworkEntry } from "../_types";
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
/**
* Card for a regular per-provider framework in the Cross-Provider tab's
* "across accounts" section. Deliberately lightweight — no roll-up numbers:
* the section only enumerates which frameworks can be viewed across accounts
* (computing every framework's N-account aggregation up front would be one
* heavy roll-up call per card). The detail computes the real aggregation.
*/
export const CrossAccountFrameworkCard = ({
complianceId,
title,
version,
providerType,
accountCount,
}: CrossAccountFrameworkEntry) => {
const router = useRouter();
const searchParams = useSearchParams();
const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
const navigateToDetail = () => {
router.push(
buildCrossAccountDetailHref(
{ complianceId, title, version, providerType },
Object.fromEntries(searchParams.entries()),
),
);
};
return (
<AggregatedFrameworkCard
frameworkTitle={title}
formattedTitle={formattedTitle}
ariaLabel={`${formattedTitle} across ${PROVIDER_DISPLAY_NAMES[providerType]} providers`}
onActivate={navigateToDetail}
subtitle={
<small className="text-text-neutral-secondary truncate text-xs">
View across providers
</small>
}
>
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-1.5 text-xs">
<ProviderTypeIcon type={providerType} size={16} />
{PROVIDER_DISPLAY_NAMES[providerType]}
</span>
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
{accountCount} providers
</span>
</div>
</AggregatedFrameworkCard>
);
};
@@ -0,0 +1,259 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCompliancesOverview } from "@/actions/compliances";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { CrossAccountOverviewSection } from "./cross-account-overview-section";
vi.mock("@/actions/providers", () => ({
getAllProviders: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
getScans: vi.fn(),
}));
vi.mock("@/actions/compliances", () => ({
getCompliancesOverview: vi.fn(),
}));
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
ProviderTypeIcon: () => <span aria-hidden="true" />,
}));
vi.mock("./cross-account-framework-card", () => ({
CrossAccountFrameworkCard: ({
complianceId,
providerType,
}: {
complianceId: string;
providerType: string;
}) => (
<div data-testid="cross-account-card">
{providerType}:{complianceId}
</div>
),
}));
// Only the fields the section reads; the full ProviderProps shape is not
// needed for these tests.
type ProvidersResponse = Awaited<ReturnType<typeof getAllProviders>>;
const providersResponse = (
providers: Array<{ id: string; type: string }>,
): ProvidersResponse =>
({
data: providers.map(({ id, type }) => ({
id,
attributes: { provider: type, uid: `uid-${id}`, alias: null },
})),
}) as unknown as ProvidersResponse;
const scansFor = (scans: Array<{ id: string; providerId: string }>) => ({
data: scans.map(({ id, providerId }) => ({
id,
relationships: { provider: { data: { id: providerId } } },
})),
included: [
{ type: "providers", id: "aws-1", attributes: { provider: "aws" } },
{ type: "providers", id: "aws-2", attributes: { provider: "aws" } },
{ type: "providers", id: "gcp-1", attributes: { provider: "gcp" } },
],
});
const renderSection = async (
searchParams: Record<string, string | string[] | undefined> = {},
) => render(await CrossAccountOverviewSection({ searchParams }));
describe("CrossAccountOverviewSection", () => {
beforeEach(() => {
vi.mocked(getAllProviders).mockReset();
vi.mocked(getScans).mockReset();
vi.mocked(getCompliancesOverview).mockReset();
});
it("renders nothing when no provider type has two or more accounts", async () => {
// Given: one AWS and one GCP account — no multi-account type.
vi.mocked(getAllProviders).mockResolvedValue(
providersResponse([
{ id: "aws-1", type: "aws" },
{ id: "gcp-1", type: "gcp" },
]),
);
vi.mocked(getScans).mockResolvedValue(scansFor([]));
// When
const { container } = await renderSection();
// Then: single-account tenants keep the tab unchanged.
expect(container).toBeEmptyDOMElement();
expect(getCompliancesOverview).not.toHaveBeenCalled();
});
it("lists the eligible type's frameworks, excluding universal and ThreatScore", async () => {
// Given: two AWS accounts (eligible) and one GCP account (not).
vi.mocked(getAllProviders).mockResolvedValue(
providersResponse([
{ id: "aws-1", type: "aws" },
{ id: "aws-2", type: "aws" },
{ id: "gcp-1", type: "gcp" },
]),
);
vi.mocked(getScans).mockResolvedValue(
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
);
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "cis_2.0_aws",
attributes: { framework: "CIS", version: "2.0" },
},
// Universal frameworks have their own cross-provider cards above.
{
id: "csa_ccm_4.0",
attributes: { framework: "CSA-CCM", version: "4.0" },
},
// ThreatScore is excluded, matching the per-scan grid.
{
id: "prowler_threatscore_aws",
attributes: { framework: "ProwlerThreatScore", version: "1.0" },
},
],
});
// When
await renderSection();
// Then: one collapsed group per eligible type, counts on the header,
// cards revealed only on expand.
expect(screen.getByText("Across providers")).toBeInTheDocument();
const providerName = screen.getByText("AWS");
const providerSummary = screen.getByText("1 framework · 2 providers");
expect(providerName.parentElement).toContainElement(providerSummary);
expect(screen.queryByText("Google Cloud")).not.toBeInTheDocument();
expect(screen.queryByTestId("cross-account-card")).not.toBeInTheDocument();
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Item aws" }));
const cards = screen.getAllByTestId("cross-account-card");
expect(cards).toHaveLength(1);
expect(cards[0]).toHaveTextContent("aws:cis_2.0_aws");
expect(getCompliancesOverview).toHaveBeenCalledWith({ scanId: "scan-1" });
});
it("respects the tab's provider type filter", async () => {
// Given: AWS is eligible but filtered out.
vi.mocked(getAllProviders).mockResolvedValue(
providersResponse([
{ id: "aws-1", type: "aws" },
{ id: "aws-2", type: "aws" },
]),
);
vi.mocked(getScans).mockResolvedValue(
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
);
// When
const { container } = await renderSection({
"filter[provider_type__in]": "gcp",
});
// Then
expect(container).toBeEmptyDOMElement();
expect(getCompliancesOverview).not.toHaveBeenCalled();
});
it("scopes provider counts to the active account and group filters", async () => {
// Given: three AWS providers exist, but only two match the active filters.
vi.mocked(getAllProviders).mockImplementation(async ({ filters } = {}) => {
const isFiltered =
filters?.["filter[id__in]"] === "aws-1,aws-2" &&
filters?.["filter[provider_groups__in]"] === "group-1";
return providersResponse(
isFiltered
? [
{ id: "aws-1", type: "aws" },
{ id: "aws-2", type: "aws" },
]
: [
{ id: "aws-1", type: "aws" },
{ id: "aws-2", type: "aws" },
{ id: "aws-3", type: "aws" },
],
);
});
vi.mocked(getScans).mockResolvedValue(
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
);
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "cis_2.0_aws",
attributes: { framework: "CIS", version: "2.0" },
},
],
});
// When
await renderSection({
"filter[provider_id__in]": "aws-1,aws-2",
"filter[provider_groups__in]": "group-1",
});
// Then: the overview count matches the same provider set as the detail.
expect(screen.getByText("1 framework · 2 providers")).toBeInTheDocument();
expect(screen.queryByText(/3 providers/)).not.toBeInTheDocument();
});
it("loads one representative completed scan for every eligible provider type", async () => {
// Given: two eligible types whose representative scans must be resolved
// independently, regardless of how many other scans the tenant has.
vi.mocked(getAllProviders).mockResolvedValue(
providersResponse([
{ id: "aws-1", type: "aws" },
{ id: "aws-2", type: "aws" },
{ id: "gcp-1", type: "gcp" },
{ id: "gcp-2", type: "gcp" },
]),
);
vi.mocked(getScans).mockImplementation(async ({ filters }) => {
const providerType = (
filters as Record<string, string | undefined> | undefined
)?.["filter[provider_type]"];
if (providerType === "aws") {
return scansFor([{ id: "scan-aws", providerId: "aws-1" }]);
}
if (providerType === "gcp") {
return scansFor([{ id: "scan-gcp", providerId: "gcp-1" }]);
}
return scansFor([]);
});
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "framework-1",
attributes: { framework: "Framework", version: "1.0" },
},
],
});
// When
await renderSection();
// Then
expect(screen.getByText("AWS")).toBeInTheDocument();
expect(screen.getByText("Google Cloud")).toBeInTheDocument();
expect(getScans).toHaveBeenCalledTimes(2);
expect(getCompliancesOverview).toHaveBeenCalledWith({
scanId: "scan-aws",
});
expect(getCompliancesOverview).toHaveBeenCalledWith({
scanId: "scan-gcp",
});
});
});
@@ -0,0 +1,195 @@
import { getCompliancesOverview } from "@/actions/compliances";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { Accordion } from "@/components/shadcn/accordion/Accordion";
import {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitle,
} from "@/components/shadcn/section/section";
import type { SearchParamsProps } from "@/types";
import type { ComplianceOverviewData } from "@/types/compliance";
import {
isKnownProviderType,
type KnownProviderType,
PROVIDER_DISPLAY_NAMES,
} from "@/types/providers";
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
import type { CrossAccountFrameworkEntry } from "../_types";
import { CrossAccountFrameworkCard } from "./cross-account-framework-card";
/** Only provider types with at least this many accounts get cross-account
* cards — with a single account the view is identical to the per-scan one. */
const MIN_ACCOUNTS = 2;
/**
* Server island for the "across accounts" section of the Cross-Provider tab:
* for every provider type with 2+ accounts, lists the regular (per-provider)
* frameworks that can be viewed aggregated across that type's accounts.
*
* The framework list per type comes from a completed scan of any account of
* that type (frameworks are a property of the provider type, not of the
* account). Universal frameworks are excluded — they already have
* their own cross-provider cards above. Renders nothing when no provider
* type qualifies, keeping the tab unchanged for single-account tenants.
* Best-effort by design: a type whose scan or framework list fails to load
* is dropped from the section rather than failing the tab.
*/
export const CrossAccountOverviewSection = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const providerFilters = {
"filter[provider_type__in]":
searchParams["filter[provider_type__in]"]?.toString(),
"filter[id__in]": searchParams["filter[provider_id__in]"]?.toString(),
"filter[provider_groups__in]":
searchParams["filter[provider_groups__in]"]?.toString(),
};
const providerTypeFilter =
providerFilters["filter[provider_type__in]"]?.split(",").filter(Boolean) ??
[];
const providersData = await getAllProviders({ filters: providerFilters });
const accountCounts = new Map<KnownProviderType, number>();
const providerIdsByType = new Map<KnownProviderType, string[]>();
for (const provider of providersData?.data || []) {
const type = provider.attributes.provider;
if (!isKnownProviderType(type)) continue;
accountCounts.set(type, (accountCounts.get(type) ?? 0) + 1);
providerIdsByType.set(type, [
...(providerIdsByType.get(type) ?? []),
provider.id,
]);
}
const eligibleTypes = Array.from(accountCounts.entries())
.filter(
([type, count]) =>
count >= MIN_ACCOUNTS &&
(providerTypeFilter.length === 0 || providerTypeFilter.includes(type)),
)
.map(([type]) => type)
.sort();
if (eligibleTypes.length === 0) return null;
// Resolve one representative scan independently for every eligible type.
// A single global scans page can omit less-recent provider types on tenants
// with a large scan history.
const scansByType = await Promise.all(
eligibleTypes.map(async (type) => {
const scansData = await getScans({
filters: {
"filter[state]": "completed",
"filter[provider_type]": type,
"filter[provider__in]": (providerIdsByType.get(type) ?? []).join(","),
},
pageSize: 1,
fields: { scans: "name" },
});
const scanId = scansData?.data?.[0]?.id;
return scanId ? ([type, scanId] as const) : null;
}),
);
const representativeScanByType = new Map(
scansByType.filter((entry) => entry !== null),
);
const universalIds = new Set(
CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId),
);
const entriesByType = await Promise.all(
Array.from(representativeScanByType.entries()).map(
async ([type, scanId]) => {
const compliancesData = await getCompliancesOverview({ scanId });
const frameworks: ComplianceOverviewData[] = Array.isArray(
compliancesData?.data,
)
? compliancesData.data
: [];
return frameworks
.filter(
(compliance) =>
compliance.attributes.framework !== "ProwlerThreatScore" &&
!universalIds.has(compliance.id),
)
.map(
(compliance): CrossAccountFrameworkEntry => ({
complianceId: compliance.id,
title: compliance.attributes.framework,
version: compliance.attributes.version,
providerType: type,
accountCount: accountCounts.get(type) ?? 0,
}),
)
.sort((a, b) => a.title.localeCompare(b.title));
},
),
);
const groups = entriesByType
.filter((entries) => entries.length > 0)
.sort((a, b) => a[0].providerType.localeCompare(b[0].providerType));
if (groups.length === 0) return null;
// One collapsed group per provider type instead of a flat grid: with
// several multi-account types connected, the flat grid piles up dozens of
// cards (each type ships 20-40 frameworks) and buries the universal
// section's hierarchy. Collapsed-by-default keeps the catalog scannable —
// the header carries the counts, expanding reveals that type's cards.
const accordionItems: AccordionItemProps[] = groups.map((entries) => {
const { providerType, accountCount } = entries[0];
return {
key: providerType,
title: (
<span className="flex min-w-0 items-center gap-3">
<span className="flex shrink-0 items-center gap-2 text-sm font-medium">
<ProviderTypeIcon type={providerType} size={18} />
{PROVIDER_DISPLAY_NAMES[providerType]}
</span>
<span className="text-text-neutral-tertiary truncate text-xs">
{entries.length} {entries.length === 1 ? "framework" : "frameworks"}{" "}
· {accountCount} providers
</span>
</span>
),
content: (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{entries.map((entry) => (
<CrossAccountFrameworkCard
key={`${entry.providerType}-${entry.complianceId}`}
{...entry}
/>
))}
</div>
),
items: [],
};
});
return (
<Section>
<SectionHeader>
<SectionTitle>Across providers</SectionTitle>
<SectionDescription>
Single-provider frameworks aggregated across every provider of the
same type, using each provider&apos;s latest completed scan. Expand a
provider type to browse its frameworks.
</SectionDescription>
</SectionHeader>
<SectionContent>
<Accordion items={accordionItems} selectionMode="multiple" />
</SectionContent>
</Section>
);
};
@@ -0,0 +1,54 @@
"use client";
import type { Requirement } from "@/types/compliance";
import type {
CrossAccountAccountRef,
CrossAccountRequirementExtras,
} from "../_types";
import { AggregatedRequirementContent } from "./aggregated-requirement-content";
interface CrossAccountRequirementContentProps {
/** The requirement as produced by the framework mapper (roll-up level). */
requirement: Requirement;
extras: CrossAccountRequirementExtras;
accountMeta: CrossAccountAccountRef[];
framework: string;
}
/**
* Combined findings view for a cross-account requirement: the requirement
* detail rendered once and a single findings table querying every
* contributing account's scan at once. Unlike the cross-provider variant
* there is no per-provider check labeling — every account shares one check
* set. Mounts lazily: the accordion unmounts collapsed content, so the
* combined fetch only fires on expand.
*/
export const CrossAccountRequirementContent = ({
requirement,
extras,
accountMeta,
framework,
}: CrossAccountRequirementContentProps) => {
const contributingAccounts = accountMeta.filter(
(account) => extras.accounts[account.id],
);
const scanIds = Array.from(
new Set(
contributingAccounts.flatMap(
(account) => extras.scanIdsByAccount[account.id] ?? [],
),
),
);
return (
<AggregatedRequirementContent
requirement={requirement}
scanIds={scanIds}
framework={framework}
emptyMessage="No account scan contributed to this requirement with the current filters."
/>
);
};
@@ -1,23 +1,19 @@
import { Info } from "lucide-react";
import Image from "next/image";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import {
ClientAccordionWrapper,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import type { Framework, RequirementsTotals } from "@/types/compliance";
import {
getCrossProviderComplianceOverview,
getLatestCrossProviderPdf,
} from "../_actions/cross-provider";
import {
getAggregatedInitialExpandedKeys,
getAggregatedRequirementsTotals,
} from "../_lib/aggregated-compliance-detail";
import { toCrossProviderAccordionItems } from "../_lib/cross-provider-accordion";
import {
buildRequirementExtrasMap,
@@ -30,6 +26,7 @@ import {
} from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
@@ -114,14 +111,7 @@ export const CrossProviderDetail = async ({
const extras = buildRequirementExtrasMap(attrs);
const providerBreakdown = computeProviderBreakdown(attrs);
const totals: RequirementsTotals = data.reduce(
(acc: RequirementsTotals, framework: Framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const totals = getAggregatedRequirementsTotals(data);
const accordionItems = toCrossProviderAccordionItems(
data,
extras,
@@ -129,18 +119,11 @@ export const CrossProviderDetail = async ({
);
const topFailedResult = mapper.getTopFailedSections(data);
// Same `${framework.name}-${category.name}` key scheme as the per-scan
// detail, so ?section= deep links (e.g. from Top Failed Sections) work.
const initialExpandedKeys: string[] = [];
if (targetSection) {
const candidates = new Set(
data.map((framework: Framework) => `${framework.name}-${targetSection}`),
);
const match = accordionItems.find((item) => candidates.has(item.key));
if (match) {
initialExpandedKeys.push(match.key);
}
}
const initialExpandedKeys = getAggregatedInitialExpandedKeys(
data,
accordionItems,
targetSection,
);
const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === complianceId,
@@ -169,74 +152,45 @@ export const CrossProviderDetail = async ({
).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}
<AggregatedComplianceDetail
compliancetitle={compliancetitle}
logoPath={logoPath}
title={
<span className="truncate text-sm font-medium">
{attrs.name || compliancetitle.split("-").join(" ")}
</span>
}
description={
<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>
}
headerLink={<CrossProviderHubLink complianceId={complianceId} />}
reportAction={
<CrossProviderPdfButton
complianceId={complianceId}
filters={{ ...filters, scanIds: attrs.scan_ids }}
latestPdf={latestPdf}
/>
<ProviderCoverageCard breakdown={providerBreakdown} />
<TopFailedSectionsCard
sections={topFailedResult.items}
dataType={topFailedResult.type}
prepopulated={topFailedResult.prepopulated}
}
filters={
<CrossProviderFilters
providerTypes={compatibleTypes}
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
</div>
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
}
totals={totals}
coverage={<ProviderCoverageCard breakdown={providerBreakdown} />}
topFailed={{
sections: topFailedResult.items,
dataType: topFailedResult.type,
prepopulated: topFailedResult.prepopulated,
}}
accordionItems={accordionItems}
initialExpandedKeys={initialExpandedKeys}
/>
);
};
@@ -31,8 +31,10 @@ export interface CrossProviderGroupOption {
}
interface CrossProviderFiltersProps {
/** Provider types offered by the visible universal frameworks. */
providerTypes: readonly KnownProviderType[];
/** Provider types offered by the visible universal frameworks. Omit (or
* pass empty) to hide the type select — the cross-account detail does,
* since its provider type is fixed by the framework being viewed. */
providerTypes?: readonly KnownProviderType[];
providerAccounts: CrossProviderAccountOption[];
providerGroups: CrossProviderGroupOption[];
}
@@ -89,14 +91,16 @@ export const CrossProviderFilters = ({
}: 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],
}))}
/>
{providerTypes && providerTypes.length > 0 && (
<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"
@@ -1,27 +1,23 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { Progress } from "@/components/shadcn/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
getScoreIndicatorClass,
type ScoreColorVariant,
} from "@/lib/compliance/score-utils";
import type { ScoreColorVariant } from "@/lib/compliance/score-utils";
import { cn } from "@/lib/utils";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { buildCrossProviderDetailHref } from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
export const CrossProviderFrameworkCard = ({
complianceId,
title,
@@ -60,103 +56,66 @@ export const CrossProviderFrameworkCard = ({
};
return (
<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();
}
}}
<AggregatedFrameworkCard
frameworkTitle={title}
formattedTitle={formattedTitle}
ariaLabel={formattedTitle}
onActivate={navigateToDetail}
tooltip={description}
subtitle={
<small className="truncate">
<span className="mr-1 text-xs font-semibold">
{requirementsPassed} / {totalRequirements}
</span>
Passing Requirements
</small>
}
>
<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 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>
</CardContent>
</Card>
<Progress
aria-label="Cross-provider compliance score"
value={ratingPercentage}
variant={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>
</AggregatedFrameworkCard>
);
};
@@ -3,6 +3,13 @@ import { AlertTriangle, Info } from "lucide-react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitle,
} from "@/components/shadcn/section/section";
import { SearchParamsProps } from "@/types";
import type { KnownProviderType } from "@/types/providers";
@@ -180,11 +187,25 @@ export const CrossProviderOverview = async ({
</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>
<Section>
<SectionHeader>
<SectionTitle>Across provider types</SectionTitle>
<SectionDescription>
Universal frameworks aggregated across every compatible provider
type, using the latest completed scan of each provider.
</SectionDescription>
</SectionHeader>
<SectionContent>
<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>
</SectionContent>
</Section>
</div>
);
};
@@ -27,14 +27,18 @@ beforeAll(() => {
const {
generatePdfMock,
generateAccountPdfMock,
trackAndPollMock,
downloadPdfMock,
downloadAccountPdfMock,
toastMock,
storeState,
} = vi.hoisted(() => ({
generatePdfMock: vi.fn(),
generateAccountPdfMock: vi.fn(),
trackAndPollMock: vi.fn(),
downloadPdfMock: vi.fn(),
downloadAccountPdfMock: vi.fn(),
toastMock: vi.fn(),
storeState: {
tasks: {} as Record<
@@ -54,6 +58,10 @@ vi.mock("../_actions/cross-provider", () => ({
generateCrossProviderPdf: generatePdfMock,
}));
vi.mock("../_actions/cross-account", () => ({
generateCrossAccountPdf: generateAccountPdfMock,
}));
vi.mock("../_lib/cross-provider-pdf", () => ({
CROSS_PROVIDER_PDF_TASK_KIND: "cross-provider-pdf",
buildCrossProviderPdfTaskScope: vi.fn(() => "scope-1"),
@@ -61,6 +69,13 @@ vi.mock("../_lib/cross-provider-pdf", () => ({
crossProviderPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
}));
vi.mock("../_lib/cross-account-pdf", () => ({
CROSS_ACCOUNT_PDF_TASK_KIND: "cross-account-pdf",
buildCrossAccountPdfTaskScope: vi.fn(() => "account-scope-1"),
downloadCrossAccountPdf: downloadAccountPdfMock,
crossAccountPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
}));
vi.mock("@/store/task-watcher/store", () => ({
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
trackAndPollTask: trackAndPollMock,
@@ -205,6 +220,56 @@ describe("CrossProviderPdfButton", () => {
await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-8"));
});
it("switches to the cross-account plumbing when providerType is set", async () => {
// Given
generateAccountPdfMock.mockResolvedValue({ taskId: "task-acc-1" });
const user = userEvent.setup();
render(<CrossProviderPdfButton {...props} providerType="aws" />);
// When
await openGenerateModal(user);
await user.click(screen.getByRole("button", { name: /^generate$/i }));
// Then — the cross-account action and task kind are used, not the
// cross-provider ones.
await waitFor(() =>
expect(generateAccountPdfMock).toHaveBeenCalledTimes(1),
);
expect(generateAccountPdfMock).toHaveBeenCalledWith({
complianceId: "csa_ccm_4.0",
providerType: "aws",
filters: props.filters,
reportName: undefined,
});
expect(generatePdfMock).not.toHaveBeenCalled();
expect(trackAndPollMock).toHaveBeenCalledWith({
taskId: "task-acc-1",
kind: "cross-account-pdf",
meta: expect.objectContaining({ scopeKey: "account-scope-1" }),
});
});
it("downloads the latest report through the cross-account action", async () => {
const user = userEvent.setup();
render(
<CrossProviderPdfButton
{...props}
providerType="aws"
latestPdf={{ taskId: "task-acc-7", filename: "aws-latest.pdf" }}
/>,
);
await user.click(screen.getByRole("button", { name: /report/i }));
await user.click(
await screen.findByRole("menuitem", { name: /download latest/i }),
);
await waitFor(() =>
expect(downloadAccountPdfMock).toHaveBeenCalledWith("task-acc-7"),
);
expect(downloadPdfMock).not.toHaveBeenCalled();
});
it("does not offer a completed report from a different filter scope", async () => {
// Given
storeState.tasks = {
@@ -18,7 +18,13 @@ import {
useTaskWatcherStore,
} from "@/store/task-watcher/store";
import { generateCrossAccountPdf } from "../_actions/cross-account";
import { generateCrossProviderPdf } from "../_actions/cross-provider";
import {
buildCrossAccountPdfTaskScope,
CROSS_ACCOUNT_PDF_TASK_KIND,
downloadCrossAccountPdf,
} from "../_lib/cross-account-pdf";
import {
buildCrossProviderPdfTaskScope,
CROSS_PROVIDER_PDF_TASK_KIND,
@@ -31,6 +37,10 @@ import type {
interface CrossProviderPdfButtonProps {
complianceId: string;
/** Set to switch the button to cross-account mode: same UI, but the
* generate/download/latest plumbing targets the cross-account endpoints
* and task kind for this provider type's accounts. */
providerType?: 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;
@@ -41,18 +51,28 @@ interface CrossProviderPdfButtonProps {
export const CrossProviderPdfButton = ({
complianceId,
providerType,
filters,
latestPdf,
}: CrossProviderPdfButtonProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [reportName, setReportName] = useState("");
const [submitting, setSubmitting] = useState(false);
const taskScope = buildCrossProviderPdfTaskScope(complianceId, filters);
const isCrossAccount = providerType !== undefined;
const taskKind = isCrossAccount
? CROSS_ACCOUNT_PDF_TASK_KIND
: CROSS_PROVIDER_PDF_TASK_KIND;
const downloadPdf = isCrossAccount
? downloadCrossAccountPdf
: downloadCrossProviderPdf;
const taskScope = isCrossAccount
? buildCrossAccountPdfTaskScope(complianceId, providerType, filters)
: buildCrossProviderPdfTaskScope(complianceId, filters);
const isGenerating = useTaskWatcherStore((state) =>
Object.values(state.tasks).some(
(task) =>
task.kind === CROSS_PROVIDER_PDF_TASK_KIND &&
task.kind === taskKind &&
task.status === TASK_WATCHER_STATUS.PENDING &&
task.meta.scopeKey === taskScope,
),
@@ -61,7 +81,7 @@ export const CrossProviderPdfButton = ({
Object.values(state.tasks).reduce<(typeof state.tasks)[string] | undefined>(
(latest, task) => {
if (
task.kind !== CROSS_PROVIDER_PDF_TASK_KIND ||
task.kind !== taskKind ||
task.status !== TASK_WATCHER_STATUS.READY ||
task.meta.scopeKey !== taskScope
) {
@@ -83,11 +103,18 @@ export const CrossProviderPdfButton = ({
const handleGenerate = async () => {
setSubmitting(true);
try {
const result = await generateCrossProviderPdf({
complianceId,
filters,
reportName: reportName.trim() || undefined,
});
const result = isCrossAccount
? await generateCrossAccountPdf({
complianceId,
providerType,
filters,
reportName: reportName.trim() || undefined,
})
: await generateCrossProviderPdf({
complianceId,
filters,
reportName: reportName.trim() || undefined,
});
if ("error" in result) {
toast({
@@ -106,7 +133,7 @@ export const CrossProviderPdfButton = ({
});
await trackAndPollTask({
taskId: result.taskId,
kind: CROSS_PROVIDER_PDF_TASK_KIND,
kind: taskKind,
meta: {
complianceId,
scopeKey: taskScope,
@@ -158,7 +185,7 @@ export const CrossProviderPdfButton = ({
icon={<DownloadIcon />}
label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`}
description={availablePdf.filename}
onSelect={() => downloadCrossProviderPdf(availablePdf.taskId)}
onSelect={() => downloadPdf(availablePdf.taskId)}
/>
)}
<ActionDropdownItem
@@ -172,8 +199,16 @@ export const CrossProviderPdfButton = ({
<Modal
open={dialogOpen}
onOpenChange={setDialogOpen}
title="Generate Cross-Provider Report"
description="The report covers the providers, accounts and filters currently applied to this view."
title={
isCrossAccount
? "Generate Cross-Account Report"
: "Generate Cross-Provider Report"
}
description={
isCrossAccount
? "The report covers the accounts and filters currently applied to this view."
: "The report covers the providers, accounts and filters currently applied to this view."
}
size="xl"
>
<form
@@ -1,12 +1,13 @@
"use client";
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import type { Requirement } from "@/types/compliance";
import { PROVIDER_TYPES } from "@/types/providers";
import { invertCheckIdsByProvider } from "../_lib/cross-provider-adapter";
import type { CrossProviderRequirementExtras } from "../_types";
import { AggregatedRequirementContent } from "./aggregated-requirement-content";
interface CrossProviderRequirementContentProps {
/** The requirement as produced by the framework mapper (roll-up level). */
requirement: Requirement;
@@ -31,15 +32,6 @@ export const CrossProviderRequirementContent = ({
(type) => extras.providers[type],
);
if (contributingTypes.length === 0) {
return (
<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] ?? []),
@@ -47,12 +39,12 @@ export const CrossProviderRequirementContent = ({
);
return (
<ClientAccordionContent
<AggregatedRequirementContent
requirement={requirement}
scanIds={scanIds}
framework={framework}
checkProviders={invertCheckIdsByProvider(extras.checkIdsByProvider)}
disableFindings={requirement.check_ids.length === 0}
emptyMessage="No provider scan contributed to this requirement with the current filters."
/>
);
};
@@ -0,0 +1,46 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CrossAccountOverviewSkeleton,
CrossProviderOverviewSkeleton,
} from "./multiple-scans-skeleton";
describe("Multiple Scans skeletons", () => {
it("mirrors the cross-provider overview layout", () => {
// Given / When
render(<CrossProviderOverviewSkeleton />);
// Then
const loadingState = screen.getByRole("status", {
name: "Loading across provider types",
});
expect(
within(loadingState).getByRole("heading", {
name: "Across provider types",
}),
).toBeInTheDocument();
expect(
loadingState.querySelectorAll('[data-skeleton-kind="filter"]'),
).toHaveLength(3);
expect(
loadingState.querySelectorAll('[data-skeleton-kind="framework-card"]'),
).toHaveLength(3);
});
it("mirrors the cross-account provider groups", () => {
// Given / When
render(<CrossAccountOverviewSkeleton />);
// Then
const loadingState = screen.getByRole("status", {
name: "Loading across providers",
});
expect(
within(loadingState).getByRole("heading", {
name: "Across providers",
}),
).toBeInTheDocument();
expect(within(loadingState).getAllByRole("button")).toHaveLength(2);
});
});
@@ -0,0 +1,126 @@
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { Accordion } from "@/components/shadcn/accordion/Accordion";
import { Card, CardContent } from "@/components/shadcn/card/card";
import {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitle,
} from "@/components/shadcn/section/section";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
const FILTER_SKELETON_COUNT = 3;
const FRAMEWORK_CARD_SKELETON_COUNT = 3;
const PROVIDER_GROUP_SKELETON_COUNT = 2;
const PROVIDER_CHIP_SKELETON_COUNT = 5;
const FrameworkCardSkeleton = () => (
<Card
variant="base"
padding="md"
data-skeleton-kind="framework-card"
aria-hidden="true"
>
<CardContent>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-3">
<Skeleton className="size-10 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-8" />
</div>
<Skeleton className="h-2.5 w-full" />
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{Array.from({ length: PROVIDER_CHIP_SKELETON_COUNT }).map(
(_, index) => (
<Skeleton key={index} className="size-[18px] rounded-sm" />
),
)}
</div>
<Skeleton className="h-3 w-28" />
</div>
</div>
</CardContent>
</Card>
);
const providerGroupItems: AccordionItemProps[] = Array.from({
length: PROVIDER_GROUP_SKELETON_COUNT,
}).map((_, index) => ({
key: `provider-skeleton-${index}`,
title: (
<span className="flex min-w-0 items-center gap-3" aria-hidden="true">
<Skeleton className="size-[18px] shrink-0 rounded-sm" />
<Skeleton className="h-4 w-20 shrink-0" />
<Skeleton className="h-3 w-36" />
</span>
),
content: null,
items: [],
isDisabled: true,
}));
export const CrossProviderOverviewSkeleton = () => (
<div
role="status"
aria-label="Loading across provider types"
aria-busy="true"
className="flex flex-col gap-6"
>
<div className="flex flex-wrap items-center gap-4" aria-hidden="true">
{Array.from({ length: FILTER_SKELETON_COUNT }).map((_, index) => (
<Skeleton
key={index}
data-skeleton-kind="filter"
className="h-10 w-full sm:max-w-[280px] sm:min-w-[180px] sm:flex-1"
/>
))}
</div>
<Section>
<SectionHeader>
<SectionTitle>Across provider types</SectionTitle>
<SectionDescription>
Universal frameworks aggregated across every compatible provider type,
using the latest completed scan of each provider.
</SectionDescription>
</SectionHeader>
<SectionContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: FRAMEWORK_CARD_SKELETON_COUNT }).map(
(_, index) => (
<FrameworkCardSkeleton key={index} />
),
)}
</div>
</SectionContent>
</Section>
</div>
);
export const CrossAccountOverviewSkeleton = () => (
<Section role="status" aria-label="Loading across providers" aria-busy="true">
<SectionHeader>
<SectionTitle>Across providers</SectionTitle>
<SectionDescription>
Single-provider frameworks aggregated across every provider of the same
type, using each provider&apos;s latest completed scan. Expand a
provider type to browse its frameworks.
</SectionDescription>
</SectionHeader>
<SectionContent>
<Accordion items={providerGroupItems} selectionMode="multiple" />
</SectionContent>
</Section>
);
@@ -40,6 +40,7 @@ describe("ProviderCoverageCard", () => {
expect(screen.getByTestId("coverage-row-aws")).toBeInTheDocument();
expect(screen.queryByTestId("coverage-row-gcp")).not.toBeInTheDocument();
expect(screen.queryByText("No completed scan")).not.toBeInTheDocument();
expect(screen.getByText("8/10 · 1 manual")).toBeInTheDocument();
});
it("shows an empty state when no provider has a scan", () => {
@@ -3,70 +3,98 @@
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { Progress } from "@/components/shadcn/progress";
import {
getScoreColor,
getScoreIndicatorClass,
} from "@/lib/compliance/score-utils";
import { getScoreColor } from "@/lib/compliance/score-utils";
import type { KnownProviderType } from "@/types/providers";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import type { ProviderBreakdownEntry } from "../_types";
interface ProviderCoverageCardProps {
breakdown: ProviderBreakdownEntry[];
/** Pre-labeled coverage row — the cross-account detail feeds one per
* account, with the fixed provider type as the icon. */
export interface CoverageRow {
key: string;
label: string;
iconType: KnownProviderType;
pass: number;
fail: number;
manual: number;
score: number;
}
/** Per-provider pass score for the cross-provider detail: one row per
* provider with a completed scan. */
interface ProviderCoverageCardProps {
/** Cross-provider breakdown (one row per scanned provider type). */
breakdown?: ProviderBreakdownEntry[];
/** Pre-labeled rows (cross-account: one per account). Wins over
* `breakdown` when both are given. */
rows?: CoverageRow[];
title?: string;
emptyMessage?: string;
}
/** Per-column pass score for the cross-provider/cross-account details: one
* row per provider type (or account) with a completed scan. */
export const ProviderCoverageCard = ({
breakdown,
rows,
title = "Provider Coverage",
emptyMessage = "No scanned providers for this framework yet.",
}: ProviderCoverageCardProps) => {
const scannedProviders = breakdown.filter((entry) => !entry.unscanned);
const resolvedRows: CoverageRow[] =
rows ??
(breakdown ?? [])
.filter((entry) => !entry.unscanned)
.map((entry) => ({
key: entry.provider,
label: PROVIDER_DISPLAY_NAMES[entry.provider],
iconType: entry.provider,
pass: entry.pass,
fail: entry.fail,
manual: entry.manual,
score: entry.score,
}));
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>
<Card variant="base">
<div className="flex min-h-[340px] flex-col gap-6">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{/* Capped + scrollable so a long list never stretches the sibling
chart cards in the same grid row. */}
<div className="minimal-scrollbar flex max-h-[300px] flex-col gap-4 overflow-y-auto">
{resolvedRows.length === 0 && (
<p className="text-text-neutral-secondary text-sm">
{emptyMessage}
</p>
)}
{resolvedRows.map((entry) => (
<div key={entry.key} data-testid={`coverage-row-${entry.key}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIcon type={entry.iconType} size={18} />
<span className="truncate">{entry.label}</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={`${entry.label} passing score`}
value={entry.score}
variant={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>
))}
</div>
))}
</CardContent>
</CardContent>
</div>
</Card>
);
};
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { AccountStatusMap, CrossAccountAccountRef } from "../_types";
import { RequirementAccountChips } from "./requirement-account-chips";
const account = (n: number, alias: string | null): CrossAccountAccountRef => ({
id: `00000000-0000-4000-8000-00000000000${n}`,
uid: `10000000000${n}`,
alias,
});
describe("RequirementAccountChips", () => {
it("shows inline labeled chips for up to two accounts", () => {
const meta = [account(1, "prod"), account(2, null)];
render(
<RequirementAccountChips
accounts={{ [meta[0].id]: "FAIL", [meta[1].id]: "PASS" }}
accountMeta={meta}
/>,
);
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("100000000002")).toBeInTheDocument();
expect(
screen.queryByTestId("requirement-status-summary"),
).not.toBeInTheDocument();
});
it("collapses many accounts without hiding the full breakdown", async () => {
const user = userEvent.setup();
const meta = Array.from({ length: 13 }, (_, index) =>
account(index + 1, `account-${index + 1}`),
);
const accounts: AccountStatusMap = Object.fromEntries(
meta.map((entry, index) => {
const status = index < 2 ? "FAIL" : index === 2 ? "MANUAL" : "PASS";
return [entry.id, status];
}),
);
render(<RequirementAccountChips accounts={accounts} accountMeta={meta} />);
const summary = screen.getByRole("button", {
name: "Show status breakdown for 13 accounts",
});
expect(summary).toHaveTextContent("Fail×2");
expect(summary).toHaveTextContent("Manual×1");
expect(summary).toHaveTextContent("Pass×10");
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
await user.click(summary);
expect(screen.getByText(/^account-1 \(/)).toBeVisible();
expect(screen.getByText(/^account-13 \(/)).toBeVisible();
expect(screen.queryByText(/more/)).not.toBeInTheDocument();
});
it("only counts accounts that contributed a status", () => {
const meta = [account(1, "prod"), account(2, "staging"), account(3, "dev")];
render(
<RequirementAccountChips
accounts={{ [meta[0].id]: "PASS", [meta[1].id]: "PASS" }}
accountMeta={meta}
/>,
);
// Two contributing accounts → still inline, the silent third is ignored.
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("staging")).toBeInTheDocument();
expect(screen.queryByText("dev")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
"use client";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { accountDisplayLabel } from "../_lib/cross-account-adapter";
import type { AccountStatusMap, CrossAccountAccountRef } from "../_types";
import {
REQUIREMENT_ENTITY_LABEL,
RequirementStatusSummary,
} from "./requirement-status-summary";
interface RequirementAccountChipsProps {
accounts: AccountStatusMap;
/** Ordered account metadata (server-sorted by alias) so chips are stable
* across requirements. */
accountMeta: CrossAccountAccountRef[];
}
/** Text labels are wide (aliases, 12-digit uids): beyond two accounts the
* inline chips would out-crowd the requirement title, so the row switches
* to the aggregated per-status summary. */
const MAX_INLINE_ACCOUNT_CHIPS = 2;
/** Per-account status chips shown next to a cross-account requirement:
* each contributing account's short label paired with its own
* PASS/FAIL/MANUAL — the account-axis sibling of RequirementProviderChips.
* With many accounts, collapses to per-status counts + hover breakdown. */
export const RequirementAccountChips = ({
accounts,
accountMeta,
}: RequirementAccountChipsProps) => {
const entries = accountMeta.filter((account) => accounts[account.id]);
if (entries.length > MAX_INLINE_ACCOUNT_CHIPS) {
return (
<RequirementStatusSummary
entityLabel={REQUIREMENT_ENTITY_LABEL.ACCOUNTS}
entries={entries.map((account) => ({
key: account.id,
label: accountDisplayLabel(account),
status: accounts[account.id]!,
}))}
/>
);
}
return (
// shrink-0: the chips keep their one-line intrinsic width and the row
// TITLE truncates instead — compressed chips used to stack into two
// lines or get clipped at the trigger's edge on long titles.
<div className="flex shrink-0 items-center justify-end gap-2">
{entries.map((account) => (
<Tooltip key={account.id}>
<TooltipTrigger asChild>
<span
data-testid={`requirement-chip-${account.id}`}
className="inline-flex items-center gap-1"
>
<span className="text-text-neutral-secondary max-w-24 truncate text-xs">
{account.alias || account.uid}
</span>
<StatusFindingBadge
status={accounts[account.id] as FindingStatus}
size="sm"
/>
</span>
</TooltipTrigger>
<TooltipContent>{accountDisplayLabel(account)}</TooltipContent>
</Tooltip>
))}
</div>
);
};
@@ -14,20 +14,44 @@ import { PROVIDER_DISPLAY_NAMES, PROVIDER_TYPES } from "@/types/providers";
import type { ProviderStatusMap } from "../_types";
import { RequirementStatusSummary } from "./requirement-status-summary";
interface RequirementProviderChipsProps {
providers: ProviderStatusMap;
}
/** Icon chips are compact, so five fit comfortably (CSA's full provider
* set); frameworks like CIS Controls declare up to 14 provider types,
* where the row must collapse to the aggregated summary instead. */
const MAX_INLINE_PROVIDER_CHIPS = 5;
/** Per-provider status chips shown next to a cross-provider requirement:
* each contributing provider's icon paired with its own PASS/FAIL/MANUAL. */
* each contributing provider's icon paired with its own PASS/FAIL/MANUAL.
* With many provider types, collapses to per-status counts + hover
* breakdown. */
export const RequirementProviderChips = ({
providers,
}: RequirementProviderChipsProps) => {
// Iterate the canonical order so chips are stable across requirements.
const entries = PROVIDER_TYPES.filter((type) => providers[type]);
if (entries.length > MAX_INLINE_PROVIDER_CHIPS) {
return (
<RequirementStatusSummary
entries={entries.map((type) => ({
key: type,
label: PROVIDER_DISPLAY_NAMES[type],
icon: <ProviderTypeIcon type={type} size={14} />,
status: providers[type]!,
}))}
/>
);
}
return (
<div className="flex flex-wrap items-center gap-2">
// shrink-0: same rationale as RequirementAccountChips — keep the chips
// on one line and let the row title truncate instead.
<div className="flex shrink-0 items-center justify-end gap-2">
{entries.map((type) => (
<Tooltip key={type}>
<TooltipTrigger asChild>
@@ -0,0 +1,109 @@
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import { ScrollArea } from "@/components/shadcn/scroll-area";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
import type { CrossProviderStatus } from "../_types";
export interface RequirementStatusEntry {
key: string;
/** Short label shown in the breakdown popover. */
label: string;
/** Optional icon rendered before the label in the breakdown popover. */
icon?: ReactNode;
status: CrossProviderStatus;
}
/** Statuses in triage order — failures first, evidence last. */
const STATUS_ORDER: readonly CrossProviderStatus[] = ["FAIL", "MANUAL", "PASS"];
const SCROLLABLE_BREAKDOWN_MIN_ROWS = 13;
export const REQUIREMENT_ENTITY_LABEL = {
ACCOUNTS: "accounts",
PROVIDERS: "providers",
} as const;
type RequirementEntityLabel =
(typeof REQUIREMENT_ENTITY_LABEL)[keyof typeof REQUIREMENT_ENTITY_LABEL];
/**
* Aggregated per-status counts for a requirement row whose column axis has
* too many members to chip inline (many accounts of one provider type, or
* many provider types). Constant footprint regardless of N: one count badge
* per status present, with the full per-member breakdown in an accessible
* popover.
*/
export const RequirementStatusSummary = ({
entries,
entityLabel = REQUIREMENT_ENTITY_LABEL.PROVIDERS,
}: {
entries: RequirementStatusEntry[];
entityLabel?: RequirementEntityLabel;
}) => {
const counts = STATUS_ORDER.map((status) => ({
status,
count: entries.filter((entry) => entry.status === status).length,
})).filter(({ count }) => count > 0);
const breakdownList = (
<div className="flex flex-col gap-1.5">
{entries.map((entry) => (
<span
key={entry.key}
className="flex items-center justify-between gap-3"
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.icon}
<span className="max-w-48 truncate text-xs">{entry.label}</span>
</span>
<StatusFindingBadge
status={entry.status as FindingStatus}
size="sm"
/>
</span>
))}
</div>
);
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="bare"
size="link-xs"
aria-label={`Show status breakdown for ${entries.length} ${entityLabel}`}
data-testid="requirement-status-summary"
>
{counts.map(({ status, count }) => (
<span key={status} className="inline-flex items-center gap-1">
<StatusFindingBadge status={status as FindingStatus} size="sm" />
<span className="text-text-neutral-secondary text-xs tabular-nums">
×{count}
</span>
</span>
))}
</Button>
</PopoverTrigger>
<PopoverContent align="end">
{entries.length >= SCROLLABLE_BREAKDOWN_MIN_ROWS ? (
<ScrollArea size="md">{breakdownList}</ScrollArea>
) : (
breakdownList
)}
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import type { Framework } from "@/types/compliance";
import { getAggregatedInitialExpandedKeys } from "../aggregated-compliance-detail";
describe("getAggregatedInitialExpandedKeys", () => {
it("returns the ancestor path for a nested section deep link", () => {
const data = [
{ name: "Operational" },
{ name: "Organizational" },
] as Framework[];
const accordionItems: AccordionItemProps[] = [
{
key: "Operational",
title: null,
content: null,
items: [
{
key: "Operational-Access control",
title: null,
content: null,
},
],
},
];
expect(
getAggregatedInitialExpandedKeys(data, accordionItems, "Access control"),
).toEqual(["Operational", "Operational-Access control"]);
});
});
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { CrossAccountOverviewAttributes } from "../../_types";
import {
accountDisplayLabel,
buildAccountExtrasMap,
computeAccountBreakdown,
crossAccountToMapperInput,
} from "../cross-account-adapter";
const ACC1 = "11111111-1111-4111-8111-111111111111";
const ACC2 = "22222222-2222-4222-8222-222222222222";
const buildAttrs = (): CrossAccountOverviewAttributes => ({
compliance_id: "cis_2.0_aws",
provider_type: "aws",
framework: "CIS",
name: "CIS Amazon Web Services Foundations Benchmark",
version: "2.0",
description: "CIS AWS 2.0",
accounts: [
{ id: ACC1, uid: "123456789012", alias: "prod" },
{ id: ACC2, uid: "210987654321", alias: null },
],
scan_ids: ["scan-1", "scan-2"],
scan_ids_by_account: { [ACC1]: ["scan-1"], [ACC2]: ["scan-2"] },
requirements_passed: 1,
requirements_failed: 1,
requirements_manual: 0,
total_requirements: 2,
requirements: [
{
id: "1.1",
name: "Maintain current contact details",
description: "desc-1",
attributes: [{ Section: "1. IAM" }],
status: "FAIL",
accounts: { [ACC1]: "FAIL", [ACC2]: "PASS" },
check_ids: ["account_maintain_current_contact_details"],
},
{
id: "1.2",
name: "",
description: "desc-2",
attributes: [],
status: "PASS",
accounts: { [ACC1]: "PASS" },
check_ids: [],
},
],
});
describe("crossAccountToMapperInput", () => {
it("produces the mapper pair with flat check_ids and passthrough metadata", () => {
const { attributesData, requirementsData } =
crossAccountToMapperInput(buildAttrs());
expect(attributesData.data).toHaveLength(2);
expect(requirementsData.data).toHaveLength(2);
const first = attributesData.data[0];
expect(first.id).toBe("1.1");
expect(first.attributes.framework).toBe("CIS");
// The per-provider template already ships metadata as a list — it must
// feed attributes.metadata directly, not get re-wrapped.
expect(first.attributes.attributes.metadata).toEqual([
{ Section: "1. IAM" },
]);
expect(first.attributes.attributes.check_ids).toEqual([
"account_maintain_current_contact_details",
]);
expect(requirementsData.data[0].attributes.status).toBe("FAIL");
expect(requirementsData.data[1].attributes.status).toBe("PASS");
});
});
describe("buildAccountExtrasMap", () => {
it("registers every candidate name a framework mapper may compose", () => {
const extras = buildAccountExtrasMap(buildAttrs());
// CSA/CIS-Controls/DORA-style mappers compose "id - name"; CIS/CCC/PCI
// use the bare id; the generic mapper uses the bare name. All three
// must resolve to the same entry so the accordion join works for every
// framework the cross-account view serves.
const composed = extras.get("1.1 - Maintain current contact details");
expect(composed).toBeDefined();
expect(extras.get("1.1")).toBe(composed);
expect(extras.get("Maintain current contact details")).toBe(composed);
expect(composed?.accounts).toEqual({ [ACC1]: "FAIL", [ACC2]: "PASS" });
expect(composed?.checkIds).toEqual([
"account_maintain_current_contact_details",
]);
expect(composed?.scanIdsByAccount[ACC1]).toEqual(["scan-1"]);
// Nameless requirements register just the id.
expect(extras.get("1.2")).toBeDefined();
});
});
describe("computeAccountBreakdown", () => {
it("scores each account over its contributed non-manual requirements", () => {
const breakdown = computeAccountBreakdown(buildAttrs());
expect(breakdown).toHaveLength(2);
// Server account order (sorted by alias) is preserved.
expect(breakdown[0].id).toBe(ACC1);
expect(breakdown[0].label).toBe("prod (123456789012)");
expect(breakdown[0].pass).toBe(1);
expect(breakdown[0].fail).toBe(1);
expect(breakdown[0].score).toBe(50);
// Account 2 contributed only one PASS row; the requirement it skipped
// must not drag its score.
expect(breakdown[1].label).toBe("210987654321");
expect(breakdown[1].pass).toBe(1);
expect(breakdown[1].fail).toBe(0);
expect(breakdown[1].score).toBe(100);
});
});
describe("accountDisplayLabel", () => {
it("prefers alias with uid in parentheses, falls back to uid", () => {
expect(accountDisplayLabel({ id: ACC1, uid: "123", alias: "prod" })).toBe(
"prod (123)",
);
expect(accountDisplayLabel({ id: ACC2, uid: "456", alias: null })).toBe(
"456",
);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
buildCrossAccountDetailHref,
parseCrossAccountFilters,
} from "../cross-account-frameworks";
describe("buildCrossAccountDetailHref", () => {
it("builds the detail path with mode, identity and provider type", () => {
const href = buildCrossAccountDetailHref({
complianceId: "cis_2.0_aws",
title: "CIS",
version: "2.0",
providerType: "aws",
});
const url = new URL(href, "https://example.test");
expect(url.pathname).toBe("/compliance/CIS");
expect(url.searchParams.get("mode")).toBe("cross-account");
expect(url.searchParams.get("complianceId")).toBe("cis_2.0_aws");
expect(url.searchParams.get("version")).toBe("2.0");
expect(url.searchParams.get("providerType")).toBe("aws");
});
it("forwards only the cross-account filter params", () => {
const href = buildCrossAccountDetailHref(
{
complianceId: "cis_2.0_aws",
title: "CIS",
version: "2.0",
providerType: "aws",
},
{
"filter[provider_id__in]": "acc-1,acc-2",
"filter[provider_groups__in]": "group-1",
// Not part of the cross-account contract: must not leak into the link.
"filter[provider_type__in]": "aws,azure",
unrelated: "x",
},
);
const url = new URL(href, "https://example.test");
expect(url.searchParams.get("filter[provider_id__in]")).toBe("acc-1,acc-2");
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
expect(url.searchParams.has("filter[provider_type__in]")).toBe(false);
expect(url.searchParams.has("unrelated")).toBe(false);
});
});
describe("parseCrossAccountFilters", () => {
it("extracts the endpoint filters and drops empties", () => {
expect(
parseCrossAccountFilters({
"filter[provider_id__in]": "acc-1",
"filter[provider_groups__in]": "",
}),
).toEqual({ providerIds: "acc-1", providerGroups: undefined });
expect(parseCrossAccountFilters({})).toEqual({
providerIds: undefined,
providerGroups: undefined,
});
});
});
@@ -0,0 +1,312 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Same stubs as the sibling accordion tests: the mappers' own
// toAccordionItems (unused here) drag the findings/server-action chain into
// jsdom through these imports.
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: () => null }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-title",
() => ({
ComplianceAccordionTitle: ({ label }: { label: string }) => (
<span>{label}</span>
),
}),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
() => ({
// Render the row name so parity assertions can compare the per-scan
// accordion's visible titles against the cross-account builder's.
ComplianceAccordionRequirementTitle: ({
name,
type,
}: {
name: string;
type: string;
}) => (
<>
{type && <span>{type}</span>}
<span>{name}</span>
</>
),
}),
);
import { mapComplianceData as mapCis } from "@/lib/compliance/cis";
import { mapComplianceData as mapEns } from "@/lib/compliance/ens";
import {
mapComplianceData as mapGeneric,
toAccordionItems as perScanGenericItems,
} from "@/lib/compliance/generic";
import type { CrossAccountOverviewAttributes } from "../../_types";
import { toCrossAccountAccordionItems } from "../cross-account-accordion";
import { crossAccountToMapperInput } from "../cross-account-adapter";
const ACC = "11111111-1111-4111-8111-111111111111";
const accountMeta = [{ id: ACC, uid: "123456789012", alias: "prod" }];
/** End-to-end parity harness: API payload → adapter → REAL mapper → builder.
* Verifies the cross-account accordion renders the same titles/hierarchy
* the mapper's own per-scan view derives from the same data. */
const buildAttrs = (
requirements: CrossAccountOverviewAttributes["requirements"],
framework: string,
): CrossAccountOverviewAttributes => ({
compliance_id: "test",
provider_type: "aws",
framework,
name: framework,
version: "1.0",
description: "framework description",
accounts: accountMeta,
scan_ids: ["scan-1"],
scan_ids_by_account: { [ACC]: ["scan-1"] },
requirements_passed: 0,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: requirements.length,
requirements,
});
describe("cross-account pipeline parity with real mappers", () => {
it("CIS: rows show the mapper's rich 'id - description' title", () => {
// Shaped like the backend template for a CIS framework: bare id as
// name, the title in description, CIS metadata fields in attributes.
const attrs = buildAttrs(
[
{
id: "2.1.1",
name: "2.1.1",
description: "Ensure centralized root access in AWS Organizations",
attributes: [
{
Section: "2 Identity and Access Management",
Profile: "Level 1",
Description:
"Ensure centralized root access in AWS Organizations",
AssessmentStatus: "Automated",
},
],
status: "PASS",
accounts: { [ACC]: "PASS" },
check_ids: ["check_a"],
},
],
"CIS",
);
const { attributesData, requirementsData } =
crossAccountToMapperInput(attrs);
const data = mapCis(attributesData, requirementsData);
const items = toCrossAccountAccordionItems(
data,
new Map(),
"CIS",
accountMeta,
);
// Per-scan CIS: category "2. Identity and Access Management", row title
// = control label "2.1.1 - Ensure…".
expect(items[0].key).toContain("2. Identity and Access Management");
const { unmount } = render(<>{items[0].items?.[0].title}</>);
expect(
screen.getByText(
"2.1.1 - Ensure centralized root access in AWS Organizations",
),
).toBeInTheDocument();
unmount();
});
it("ENS: marcos on top, labeled control groups nested, type chip on rows", () => {
const ensRequirement = (
id: string,
marco: string,
grupo: string,
tipo: string,
) => ({
id,
name: id,
description: "Proveedor de identidad centralizado",
attributes: [
{
Marco: marco,
Categoria: "Control de acceso",
IdGrupoControl: grupo,
Tipo: tipo,
Nivel: "alto",
Dimensiones: ["trazabilidad"],
ModoEjecucion: "automático",
DescripcionControl: "Descripción del control",
},
],
status: "PASS" as const,
accounts: { [ACC]: "PASS" as const },
check_ids: ["check_a"],
});
const attrs = buildAttrs(
[
ensRequirement(
"op.acc.1.aws.iam.2",
"operacional",
"op.acc.1",
"requisito",
),
ensRequirement(
"op.acc.1.aws.iam.3",
"operacional",
"op.acc.1",
"recomendacion",
),
ensRequirement("org.1.aws.iam.1", "organizativo", "org.1", "requisito"),
],
"ENS",
);
const { attributesData, requirementsData } =
crossAccountToMapperInput(attrs);
const data = mapEns(attributesData, requirementsData);
const items = toCrossAccountAccordionItems(
data,
new Map(),
"ENS",
accountMeta,
);
// Per-scan ENS: marcos (frameworks) at the top…
expect(items.map((item) => item.key)).toEqual([
"operacional",
"organizativo",
]);
// …categories under the marco, control group as its own nested level.
const category = items[0].items?.[0];
expect(category?.key).toBe("operacional-Control de acceso");
expect(category?.items).toHaveLength(1);
const group = category?.items?.[0];
expect(group?.items).toHaveLength(2);
// Requirement rows carry the ENS type chip like per-scan.
const { unmount } = render(<>{group?.items?.[0].title}</>);
expect(screen.getByText("requisito")).toBeInTheDocument();
expect(screen.getByText("op.acc.1.aws.iam.2")).toBeInTheDocument();
unmount();
});
it("generic flat structure (GDPR-style): requirements render as top-level rows", () => {
// GDPR metadata has no distinct Section, so the generic mapper stores
// requirements directly on the framework with EMPTY categories — the
// accordion must not come out empty (regression: blank GDPR accordion).
const attrs = buildAttrs(
[
{
id: "article_25",
name: "Article 25: Data protection by design and by default",
description: "Data protection by design and by default",
attributes: [
{
Section: "Article 25: Data protection by design and by default",
},
],
status: "FAIL",
accounts: { [ACC]: "FAIL" },
check_ids: ["check_a"],
},
{
id: "article_32",
name: "Article 32: Security of processing",
description: "Security of processing",
attributes: [{ Section: "Article 32: Security of processing" }],
status: "FAIL",
accounts: { [ACC]: "FAIL" },
check_ids: ["check_b"],
},
],
"GDPR",
);
const { attributesData, requirementsData } =
crossAccountToMapperInput(attrs);
const data = mapGeneric(attributesData, requirementsData);
const items = toCrossAccountAccordionItems(
data,
new Map(),
"GDPR",
accountMeta,
);
expect(items).toHaveLength(2);
const { unmount } = render(<>{items[0].title}</>);
expect(
screen.getByText("Article 25: Data protection by design and by default"),
).toBeInTheDocument();
unmount();
});
it("PCI 3-level shape: same leaf rows as the per-scan generic accordion", () => {
// Real pci_3.2.1_aws metadata shape: every item has SubSection; the
// top-level requirements (2.1, 2.2) carry SubSection === Section, the
// children carry their parent's titled SubSection. The mapper hard-codes
// bare-id names for PCI, so bare rows are per-scan behavior, not a
// cross-account regression.
const pciItem = (id: string, subSection: string) => ({
id,
name: id,
description: `Description for ${id}`,
attributes: [
{
Section: "Requirement 2: Do not use vendor-supplied defaults",
SubSection: subSection,
},
],
status: "FAIL" as const,
accounts: { [ACC]: "FAIL" as const },
check_ids: ["check_a"],
});
const SECTION = "Requirement 2: Do not use vendor-supplied defaults";
const attrs = buildAttrs(
[
pciItem("2.1", SECTION),
pciItem("2.2", SECTION),
pciItem("2.1.a", "2.1 Always change vendor-supplied defaults"),
pciItem("2.2.1", "2.2 Develop configuration standards"),
pciItem("2.2.2", "2.2 Develop configuration standards"),
],
"PCI",
);
const { attributesData, requirementsData } =
crossAccountToMapperInput(attrs);
const data = mapGeneric(attributesData, requirementsData);
const collectLeafTitles = (items: ReturnType<typeof perScanGenericItems>) =>
items.flatMap(function walk(item): string[] {
const children = item.items ?? [];
if (children.length > 0) {
return children.flatMap(walk);
}
const { unmount, container } = render(<>{item.title}</>);
const text = container.textContent ?? "";
unmount();
return [text];
});
const crossAccountLeaves = collectLeafTitles(
toCrossAccountAccordionItems(data, new Map(), "PCI", accountMeta),
);
const perScanLeaves = collectLeafTitles(perScanGenericItems(data, "scan"));
// Same leaf rows, same visible titles (chips/badges aside). The only
// structural difference is deliberate: per-scan wraps the top-level
// requirements in a group whose label repeats the category header;
// cross-account flattens that redundant wrapper.
expect(
crossAccountLeaves.map((t) => t.replace(/Fail$/, "")).sort(),
).toEqual(perScanLeaves.sort());
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../_actions/cross-account", () => ({
getCrossAccountPdfBinary: vi.fn(),
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: vi.fn(),
ToastAction: () => null,
}));
vi.mock("@/lib/helper", () => ({
downloadFile: vi.fn(),
}));
import { buildCrossAccountPdfTaskScope } from "../cross-account-pdf";
describe("buildCrossAccountPdfTaskScope", () => {
it("keeps reports from different provider types in separate scopes", () => {
const awsScope = buildCrossAccountPdfTaskScope("cis_2.0_aws", "aws", {
scanIds: ["scan-1"],
});
const gcpScope = buildCrossAccountPdfTaskScope("cis_2.0_aws", "gcp", {
scanIds: ["scan-1"],
});
expect(awsScope).not.toBe(gcpScope);
});
});
@@ -87,6 +87,37 @@ describe("toCrossProviderAccordionItems", () => {
expect(items[0].items).toHaveLength(2);
});
it("generates unique requirement keys across controls of one category", () => {
// Two controls whose requirement lists both start at index 0 — keying
// on the requirement index alone would collide (React duplicate-key
// warning seen on the cross-account sibling with CIS categories).
const twoControls: Framework[] = [
{
...data[0],
categories: [
{
...data[0].categories[0],
controls: [
data[0].categories[0].controls[0],
{
...data[0].categories[0].controls[0],
label: "another control",
},
],
},
],
},
];
const keys = toCrossProviderAccordionItems(
twoControls,
extras,
"CSA-CCM",
)[0].items!.map((item) => item.key);
expect(new Set(keys).size).toBe(keys.length);
});
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.
@@ -0,0 +1,161 @@
import type { ReactNode } from "react";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
import type { Control, Framework, Requirement } from "@/types/compliance";
interface AggregatedComplianceAccordionOptions<TExtras> {
data: Framework[];
extras: Map<string, TExtras>;
renderStatus: (extras: TExtras) => ReactNode;
renderContent: (
requirement: Requirement,
extras: TExtras,
itemKey: string,
) => ReactNode;
missingBreakdownMessage: string;
}
/** Shared mapper-driven hierarchy for provider- and account-axis compliance.
* Axis adapters supply only the status summary and lazy findings content. */
export const toAggregatedComplianceAccordionItems = <TExtras,>({
data,
extras,
renderStatus,
renderContent,
missingBreakdownMessage,
}: AggregatedComplianceAccordionOptions<TExtras>): AccordionItemProps[] => {
const requirementItem = (
requirement: Requirement,
itemKey: string,
rowTitle: string,
): AccordionItemProps => {
const requirementExtras = extras.get(requirement.name as string);
const requirementType =
typeof requirement.type === "string" ? requirement.type : "";
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type={requirementType}
name={rowTitle}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
statusContent={
requirementExtras ? renderStatus(requirementExtras) : undefined
}
/>
),
content: requirementExtras ? (
renderContent(requirement, requirementExtras, itemKey)
) : (
<p key={`content-${itemKey}`} className="text-sm">
{missingBreakdownMessage}
</p>
),
items: [],
};
};
const controlItems = (
control: Control,
categoryName: string,
baseKey: string,
): AccordionItemProps[] => {
const groupLabel =
control.label && control.label !== categoryName
? control.label
: undefined;
if (groupLabel && control.requirements.length > 1) {
return [
{
key: baseKey,
title: (
<ComplianceAccordionTitle
label={groupLabel}
pass={control.pass}
fail={control.fail}
manual={control.manual}
/>
),
content: "",
items: control.requirements.map((requirement, requirementIndex) =>
requirementItem(
requirement,
`${baseKey}-req-${requirementIndex}`,
requirement.name as string,
),
),
},
];
}
return control.requirements.map((requirement, requirementIndex) =>
requirementItem(
requirement,
`${baseKey}-req-${requirementIndex}`,
(groupLabel ?? requirement.name) as string,
),
);
};
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
frameworkData.categories.map((category) => ({
key: `${frameworkData.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={data.length === 1}
/>
),
content: "",
items: category.controls.flatMap((control, controlIndex) =>
controlItems(
control,
category.name,
`${frameworkData.name}-${category.name}-c${controlIndex}`,
),
),
}));
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
const directRequirements =
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
if (directRequirements.length > 0) {
return directRequirements.map((requirement, requirementIndex) =>
requirementItem(
requirement,
`${frameworkData.name}-req-${requirementIndex}`,
requirement.name as string,
),
);
}
return categoryItems(frameworkData);
};
if (data.length > 1) {
return data.map((frameworkData) => ({
key: frameworkData.name,
title: (
<ComplianceAccordionTitle
label={frameworkData.name}
pass={frameworkData.pass}
fail={frameworkData.fail}
manual={frameworkData.manual}
isParentLevel
/>
),
content: "",
items: frameworkItems(frameworkData),
}));
}
return data.flatMap(frameworkItems);
};
@@ -0,0 +1,296 @@
import * as Sentry from "@sentry/nextjs";
import type { ScanBinaryResult } from "@/actions/scans/scans";
import {
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import { hasActionError, type ActionErrorResult } from "@/lib/action-errors";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import type { LatestCrossProviderPdf } from "../_types";
import {
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
} from "../_types";
const AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS = 30_000;
interface PdfEndpointErrorBody {
errors?: Array<{ detail?: string }>;
error?: string;
message?: string;
}
type AggregatedComplianceOverviewResult<TResponse> =
| {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS;
response: TResponse;
}
| {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR;
result: ActionErrorResult;
}
| {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR;
message: string;
};
const captureRequestFailure = (
error: unknown,
operation: string,
timedOut: boolean,
) => {
const capturedError =
error instanceof Error ? error : new Error(getErrorMessage(error));
Sentry.captureException(capturedError, {
tags: {
error_source: SentryErrorSource.SERVER_ACTION,
error_type: SentryErrorType.SERVER_ACTION_ERROR,
request_timed_out: timedOut,
},
level: "error",
contexts: {
api_request: { operation },
},
});
};
/** Fetch wrapper used by every aggregated-compliance endpoint. It keeps the
* static route template in telemetry, aborts stalled upstream requests, and
* always clears its timer once the request settles. */
const fetchAggregatedCompliance = async (
url: URL,
init: RequestInit,
operation: string,
): Promise<Response> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(
new Error(
`Aggregated compliance request timed out after ${AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS}ms`,
),
);
}, AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS);
try {
return await fetch(url.toString(), {
...init,
signal: controller.signal,
});
} catch (error) {
captureRequestFailure(error, operation, controller.signal.aborted);
throw error;
} finally {
clearTimeout(timeoutId);
}
};
const getPdfEndpointErrorMessage = async (
response: Response,
fallbackMessage: string,
operation: string,
): Promise<string> => {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
const errorData: PdfEndpointErrorBody | null = contentType.includes(
"text/html",
)
? null
: await response.json().catch(() => null);
if (response.status >= 500) {
Sentry.captureException(
new Error(
`Aggregated compliance PDF request failed (${response.status}) at ${operation}`,
),
{
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.SERVER_ERROR,
error_source: SentryErrorSource.SERVER_ACTION,
},
level: "error",
contexts: {
api_response: {
status: response.status,
statusText: response.statusText,
operation,
},
},
},
);
return GENERIC_SERVER_ERROR_MESSAGE;
}
return (
errorData?.errors?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
fallbackMessage
);
};
export const getAggregatedComplianceOverview = async <TResponse>(
url: URL,
operation: string,
): Promise<AggregatedComplianceOverviewResult<TResponse>> => {
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetchAggregatedCompliance(
url,
{ headers },
operation,
);
const responseData = await handleApiResponse(response);
if (hasActionError(responseData)) {
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
result: responseData,
};
}
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
response: responseData as TResponse,
};
} catch (error) {
console.error("Error fetching aggregated compliance overview:", error);
return {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
};
}
};
export const generateAggregatedCompliancePdf = async (
url: URL,
operation: string,
): Promise<{ taskId: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetchAggregatedCompliance(
url,
{ method: "POST", headers },
operation,
);
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to start PDF generation. Contact support if the issue continues.",
operation,
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) {
throw new Error("Unexpected response starting PDF generation.");
}
return { taskId };
} catch (error) {
return { error: getErrorMessage(error) };
}
};
export const getAggregatedCompliancePdfBinary = async ({
url,
operation,
defaultFilename,
}: {
url: URL;
operation: string;
defaultFilename: string;
}): Promise<ScanBinaryResult> => {
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetchAggregatedCompliance(
url,
{ headers },
operation,
);
if (response.status === 202) {
const json = await response.json();
return {
pending: true,
state: json?.data?.attributes?.state,
taskId: json?.data?.id,
};
}
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
operation,
),
);
}
const contentDisposition =
response.headers.get("content-disposition") || "";
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch?.[1] || defaultFilename;
const arrayBuffer = await response.arrayBuffer();
return {
success: true,
data: Buffer.from(arrayBuffer).toString("base64"),
filename,
};
} catch (error) {
return { error: getErrorMessage(error) };
}
};
export const getLatestAggregatedCompliancePdf = async (
url: URL,
operation: string,
): Promise<LatestCrossProviderPdf | null> => {
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetchAggregatedCompliance(
url,
{ headers },
operation,
);
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(
await getPdfEndpointErrorMessage(
response,
"Unable to check for an existing PDF report.",
operation,
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) return null;
return {
taskId,
filename: json?.data?.attributes?.result?.filename,
completedAt: json?.data?.attributes?.completed_at,
};
} catch (error) {
console.error("Error checking for an aggregated compliance PDF:", error);
return null;
}
};
@@ -0,0 +1,43 @@
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import type { Framework, RequirementsTotals } from "@/types/compliance";
export const getAggregatedRequirementsTotals = (
data: Framework[],
): RequirementsTotals =>
data.reduce(
(totals, framework) => ({
pass: totals.pass + framework.pass,
fail: totals.fail + framework.fail,
manual: totals.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
export const getAggregatedInitialExpandedKeys = (
data: Framework[],
accordionItems: AccordionItemProps[],
targetSection?: string,
): string[] => {
if (!targetSection) return [];
const candidates = new Set(
data.map((framework) => `${framework.name}-${targetSection}`),
);
const findExpandedPath = (
items: AccordionItemProps[],
ancestors: string[] = [],
): string[] | undefined => {
for (const item of items) {
const path = [...ancestors, item.key];
if (candidates.has(item.key)) return path;
const nestedMatch = findExpandedPath(item.items ?? [], path);
if (nestedMatch) return nestedMatch;
}
return undefined;
};
return findExpandedPath(accordionItems) ?? [];
};
@@ -0,0 +1,90 @@
"use client";
import type { ScanBinaryResult } from "@/actions/scans/scans";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { downloadFile } from "@/lib/helper";
import type { TaskKindHandler } from "@/store/task-watcher/store";
const normalizeScopeValue = (value: string | string[] | undefined) => {
if (Array.isArray(value)) return [...value].sort();
return (
value
?.split(",")
.map((item) => item.trim())
.filter(Boolean)
.sort()
.join(",") ?? ""
);
};
export const buildAggregatedCompliancePdfTaskScope = (
values: Record<string, string | string[] | undefined>,
): string =>
JSON.stringify(
Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
normalizeScopeValue(value),
]),
),
);
export const downloadAggregatedCompliancePdf = async ({
taskId,
getPdfBinary,
axisLabel,
}: {
taskId: string;
getPdfBinary: (taskId: string) => Promise<ScanBinaryResult>;
axisLabel: string;
}): Promise<void> => {
try {
const result = await getPdfBinary(taskId);
await downloadFile(
result,
"application/pdf",
`The ${axisLabel} compliance PDF has been downloaded successfully.`,
toast,
);
} catch {
toast({
variant: "destructive",
title: "Download failed",
description: "Could not fetch the report. Please try again later.",
});
}
};
export const createAggregatedCompliancePdfHandler = ({
axisLabel,
downloadPdf,
}: {
axisLabel: string;
downloadPdf: (taskId: string) => Promise<void>;
}): TaskKindHandler => ({
onReady: (task) => {
toast({
title: "Compliance report ready",
description: task.meta.reportLabel
? `The ${task.meta.reportLabel} ${axisLabel} PDF has been generated.`
: `The ${axisLabel} compliance PDF has been generated.`,
action: (
<ToastAction
altText="Download report"
onClick={() => downloadPdf(task.taskId)}
>
Download
</ToastAction>
),
});
},
onError: (task) => {
toast({
variant: "destructive",
title: "Report generation failed",
description:
task.error ||
`The ${axisLabel} PDF could not be generated. Try again later.`,
});
},
});
@@ -0,0 +1,39 @@
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import type { Framework } from "@/types/compliance";
import { CrossAccountRequirementContent } from "../_components/cross-account-requirement-content";
import { RequirementAccountChips } from "../_components/requirement-account-chips";
import type {
CrossAccountAccountRef,
CrossAccountRequirementExtras,
} from "../_types";
import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion";
export const toCrossAccountAccordionItems = (
data: Framework[],
extras: Map<string, CrossAccountRequirementExtras>,
framework: string,
accountMeta: CrossAccountAccountRef[],
): AccordionItemProps[] =>
toAggregatedComplianceAccordionItems({
data,
extras,
renderStatus: (requirementExtras) => (
<RequirementAccountChips
accounts={requirementExtras.accounts}
accountMeta={accountMeta}
/>
),
renderContent: (requirement, requirementExtras, itemKey) => (
<CrossAccountRequirementContent
key={`content-${itemKey}`}
requirement={requirement}
extras={requirementExtras}
accountMeta={accountMeta}
framework={framework}
/>
),
missingBreakdownMessage:
"No per-account breakdown is available for this requirement.",
});
@@ -0,0 +1,147 @@
import type {
AttributesData,
AttributesItemData,
RequirementItemData,
RequirementsData,
} from "@/types/compliance";
import type {
AccountBreakdownEntry,
CrossAccountAccountRef,
CrossAccountOverviewAttributes,
CrossAccountRequirementExtras,
} from "../_types";
/** Candidate display names a framework mapper may give this requirement.
*
* Unlike the cross-provider view (whose three universal frameworks all
* compose `id - name`), cross-account serves EVERY framework, and mappers
* disagree on the composed name: CSA/CIS-Controls/DORA use `id - name`,
* CIS/CCC/PCI use the bare `id`, the generic mapper uses the bare `name`.
* The extras map registers every candidate so the join works regardless of
* which mapper renders the framework. */
const candidateRequirementNames = (id: string, name: string): string[] => {
const candidates = [id];
if (name) {
candidates.push(name, `${id} - ${name}`);
}
return candidates;
};
/** Display label for an account column: alias when set, uid otherwise. */
export const accountDisplayLabel = (account: CrossAccountAccountRef): string =>
account.alias ? `${account.alias} (${account.uid})` : account.uid;
/**
* Convert a cross-account overview into the `{AttributesData,
* RequirementsData}` pair the per-scan compliance mappers consume — the
* cross-account sibling of `crossProviderToMapperInput`. The per-provider
* template already ships each requirement's metadata as a list, so it feeds
* `attributes.metadata` directly, and `check_ids` is the single flat list
* every account shares.
*/
export const crossAccountToMapperInput = (
attrs: CrossAccountOverviewAttributes,
): { attributesData: AttributesData; requirementsData: RequirementsData } => {
const attributeItems: AttributesItemData[] = [];
const requirementItems: RequirementItemData[] = [];
for (const requirement of attrs.requirements) {
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: requirement.check_ids ?? [],
},
},
});
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-account context for each requirement, keyed by the exact composed
* name the mappers produce, so renderers can join per-account statuses and
* scan ids onto mapped requirements without touching the mappers.
*/
export const buildAccountExtrasMap = (
attrs: CrossAccountOverviewAttributes,
): Map<string, CrossAccountRequirementExtras> => {
const extras = new Map<string, CrossAccountRequirementExtras>();
for (const requirement of attrs.requirements) {
const entry: CrossAccountRequirementExtras = {
requirementId: requirement.id,
accounts: requirement.accounts,
checkIds: requirement.check_ids ?? [],
scanIdsByAccount: attrs.scan_ids_by_account,
};
for (const name of candidateRequirementNames(
requirement.id,
requirement.name,
)) {
// First registration wins on the (unlikely) cross-requirement
// collision, so a bare-name key never hijacks another entry's id key.
if (!extras.has(name)) {
extras.set(name, entry);
}
}
}
return extras;
};
/**
* Per-account score summary for the coverage panel. Keeps the server's
* account order (sorted by alias). Score is the pass percentage over
* non-manual requirements the account contributed.
*/
export const computeAccountBreakdown = (
attrs: CrossAccountOverviewAttributes,
): AccountBreakdownEntry[] =>
attrs.accounts.map((account) => {
let pass = 0;
let fail = 0;
let manual = 0;
for (const requirement of attrs.requirements) {
const status = requirement.accounts[account.id];
if (status === "PASS") pass += 1;
else if (status === "FAIL") fail += 1;
else if (status === "MANUAL") manual += 1;
}
const scored = pass + fail;
return {
id: account.id,
label: accountDisplayLabel(account),
pass,
fail,
manual,
total: pass + fail + manual,
score: scored > 0 ? Math.round((pass / scored) * 100) : 0,
};
});
@@ -0,0 +1,44 @@
import type {
CrossAccountApiFilters,
CrossAccountFrameworkEntry,
} from "../_types";
/** Cross-account filter params forwarded from the overview into detail
* links (and consumed back by the detail page). The provider type is fixed
* per view, so unlike cross-provider there is no provider_type__in here. */
const CROSS_ACCOUNT_FILTER_PARAMS = [
"filter[provider_id__in]",
"filter[provider_groups__in]",
] as const;
/** Parses the URL filter params the cross-account endpoint accepts. Kept
* next to CROSS_ACCOUNT_FILTER_PARAMS so the overview section and the
* detail island build identical, typed filter objects. */
export const parseCrossAccountFilters = (
searchParams: Record<string, string | string[] | undefined>,
): CrossAccountApiFilters => ({
providerIds: searchParams["filter[provider_id__in]"]?.toString() || undefined,
providerGroups:
searchParams["filter[provider_groups__in]"]?.toString() || undefined,
});
export const buildCrossAccountDetailHref = (
entry: Pick<
CrossAccountFrameworkEntry,
"complianceId" | "title" | "version" | "providerType"
>,
searchParams?: Record<string, string | string[] | undefined>,
): string => {
const params = new URLSearchParams();
params.set("mode", "cross-account");
params.set("complianceId", entry.complianceId);
params.set("version", entry.version);
params.set("providerType", entry.providerType);
for (const key of CROSS_ACCOUNT_FILTER_PARAMS) {
const value = searchParams?.[key]?.toString();
if (value) params.set(key, value);
}
return `/compliance/${encodeURIComponent(entry.title)}?${params.toString()}`;
};
@@ -0,0 +1,37 @@
"use client";
import { getCrossAccountPdfBinary } from "../_actions/cross-account";
import type { CrossAccountApiFilters } from "../_types";
import {
buildAggregatedCompliancePdfTaskScope,
createAggregatedCompliancePdfHandler,
downloadAggregatedCompliancePdf,
} from "./aggregated-compliance-pdf";
export const CROSS_ACCOUNT_PDF_TASK_KIND = "cross-account-pdf";
export const buildCrossAccountPdfTaskScope = (
complianceId: string,
providerType: string,
filters: CrossAccountApiFilters,
): string =>
buildAggregatedCompliancePdfTaskScope({
complianceId,
providerType,
scanIds: filters.scanIds,
providerIds: filters.providerIds,
providerGroups: filters.providerGroups,
});
export const downloadCrossAccountPdf = (taskId: string): Promise<void> =>
downloadAggregatedCompliancePdf({
taskId,
getPdfBinary: getCrossAccountPdfBinary,
axisLabel: "cross-account",
});
export const crossAccountPdfHandler = createAggregatedCompliancePdfHandler({
axisLabel: "cross-account",
downloadPdf: downloadCrossAccountPdf,
});
@@ -1,82 +1,31 @@
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.
*/
import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion";
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: [],
};
}),
),
})),
);
toAggregatedComplianceAccordionItems({
data,
extras,
renderStatus: (requirementExtras) => (
<RequirementProviderChips providers={requirementExtras.providers} />
),
renderContent: (requirement, requirementExtras, itemKey) => (
<CrossProviderRequirementContent
key={`content-${itemKey}`}
requirement={requirement}
extras={requirementExtras}
framework={framework}
/>
),
missingBreakdownMessage:
"No per-provider breakdown is available for this requirement.",
});
@@ -1,91 +1,36 @@
"use client";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { downloadFile } from "@/lib/helper";
import type { TaskKindHandler } from "@/store/task-watcher/store";
import { getCrossProviderPdfBinary } from "../_actions/cross-provider";
import type { CrossProviderApiFilters } from "../_types";
import {
buildAggregatedCompliancePdfTaskScope,
createAggregatedCompliancePdfHandler,
downloadAggregatedCompliancePdf,
} from "./aggregated-compliance-pdf";
export const CROSS_PROVIDER_PDF_TASK_KIND = "cross-provider-pdf";
const normalizeCommaSeparatedFilter = (value?: string): string =>
value
?.split(",")
.map((item) => item.trim())
.filter(Boolean)
.sort()
.join(",") ?? "";
/** Stable identity for the exact cross-provider view a PDF represents. */
export const buildCrossProviderPdfTaskScope = (
complianceId: string,
filters: CrossProviderApiFilters,
): string =>
JSON.stringify({
buildAggregatedCompliancePdfTaskScope({
complianceId,
scanIds: [...(filters.scanIds ?? [])].sort(),
providerTypes: normalizeCommaSeparatedFilter(filters.providerTypes),
providerIds: normalizeCommaSeparatedFilter(filters.providerIds),
providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups),
scanIds: filters.scanIds,
providerTypes: filters.providerTypes,
providerIds: filters.providerIds,
providerGroups: filters.providerGroups,
});
/** Fetches the finished cross-provider PDF and hands it to the browser,
* reusing the shared base64→blob download + toast handling. Never rejects:
* it is fired from toast actions and dropdown items whose rejections would
* otherwise vanish unhandled. */
export const downloadCrossProviderPdf = async (
taskId: string,
): Promise<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.",
});
}
};
export const downloadCrossProviderPdf = (taskId: string): Promise<void> =>
downloadAggregatedCompliancePdf({
taskId,
getPdfBinary: getCrossProviderPdfBinary,
axisLabel: "cross-provider",
});
/**
* Completion handler for cross-provider PDF generation tasks. Fired by the
* generic task watcher (`@/store/task-watcher`) whenever a tracked task of
* this kind settles — including after client-side navigation (module-scope
* poll loop) or a hard reload (persisted store + `TaskPollingWatcher`).
*/
export const crossProviderPdfHandler: TaskKindHandler = {
onReady: (task) => {
toast({
title: "Compliance report ready",
description: task.meta.reportLabel
? `The ${task.meta.reportLabel} cross-provider PDF has been generated.`
: "The cross-provider compliance PDF has been generated.",
action: (
<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.",
});
},
};
export const crossProviderPdfHandler = createAggregatedCompliancePdfHandler({
axisLabel: "cross-provider",
downloadPdf: downloadCrossProviderPdf,
});
+111 -8
View File
@@ -7,14 +7,6 @@ import type { KnownProviderType, ProviderType } from "@/types/providers";
// 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;
@@ -161,3 +153,114 @@ export interface LatestCrossProviderPdf {
filename?: string;
completedAt?: string;
}
// Types for the Cloud-only cross-account compliance roll-up, backed by
// GET /cross-account-compliance-overviews (one regular per-provider framework
// aggregated across the latest scan of every account of a single provider
// type; same server-side roll-up rules as the cross-provider endpoint, with
// the column axis being the account instead of the provider type).
export const CROSS_ACCOUNT_OVERVIEW_TYPE =
"cross-account-compliance-overviews" as const;
/** Contributing account (Provider) metadata, sorted by alias server-side. */
export interface CrossAccountAccountRef {
id: string;
uid: string;
alias: string | null;
}
/** Requirement status per account, keyed by Provider UUID. */
export type AccountStatusMap = Record<string, CrossProviderStatus>;
export interface CrossAccountRequirementData {
id: string;
name: string;
description: string;
/** Framework-specific metadata list from the per-provider template
* (already unwrapped — same shape the per-scan mappers consume). */
attributes: unknown[];
status: CrossProviderStatus;
accounts: AccountStatusMap;
/** Single flat list — every account shares the provider type. */
check_ids?: string[];
}
export interface CrossAccountOverviewAttributes {
compliance_id: string;
provider_type: string;
framework: string;
name: string;
version: string;
description: string;
accounts: CrossAccountAccountRef[];
scan_ids: string[];
/** Provider UUID → scan UUIDs aggregated for that account. */
scan_ids_by_account: Record<string, string[]>;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
requirements: CrossAccountRequirementData[];
}
export interface CrossAccountOverviewData {
type: typeof CROSS_ACCOUNT_OVERVIEW_TYPE;
id: string;
attributes: CrossAccountOverviewAttributes;
}
export interface CrossAccountOverviewResponse {
data: CrossAccountOverviewData;
}
/** Result variants reuse the cross-provider status constants so the shared
* error components (CrossProviderErrorAlert) work unchanged. */
export type CrossAccountOverviewResult =
| {
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS;
response: CrossAccountOverviewResponse;
}
| CrossProviderOverviewActionErrorResult
| CrossProviderOverviewLoadErrorResult;
/** Filters accepted by the cross-account endpoint (comma-joined). */
export interface CrossAccountApiFilters {
scanIds?: string[];
providerIds?: string;
providerGroups?: string;
}
/** Cross-account context joined onto a mapped requirement, keyed by the
* composed requirement name the per-scan mappers produce. */
export interface CrossAccountRequirementExtras {
requirementId: string;
accounts: AccountStatusMap;
checkIds: string[];
scanIdsByAccount: Record<string, string[]>;
}
/** Card data for the per-provider frameworks section of the Cross-Provider
* tab: one regular framework of one provider type, aggregatable across
* that type's accounts. */
export interface CrossAccountFrameworkEntry {
/** Regular framework id used as filter[compliance_id] (e.g. cis_2.0_aws). */
complianceId: string;
/** Framework display name; also the [compliancetitle] path segment and the
* key getComplianceIcon resolves the framework icon from. */
title: string;
version: string;
providerType: KnownProviderType;
accountCount: number;
}
export interface AccountBreakdownEntry {
id: string;
label: string;
pass: number;
fail: number;
manual: number;
total: number;
/** 0-100 pass percentage over non-manual requirements. */
score: number;
}
@@ -13,4 +13,9 @@ describe("Compliance overview page", () => {
expect(source).toContain("ComplianceOverviewGrid");
expect(source).not.toContain("filter[search]");
});
it("uses layout-matched skeletons for the Multiple Scans islands", () => {
expect(source).toContain("<CrossProviderOverviewSkeleton />");
expect(source).toContain("<CrossAccountOverviewSkeleton />");
});
});
+51 -17
View File
@@ -6,7 +6,7 @@ import {
getCompliancesOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScans } from "@/actions/scans";
import { getScans, getScansByState } from "@/actions/scans";
import {
ComplianceSkeletonGrid,
NoScansAvailable,
@@ -25,12 +25,16 @@ import {
ScanProps,
SearchParamsProps,
} from "@/types";
import { ComplianceOverviewData } from "@/types/compliance";
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
import { CrossProviderOverview } from "./_components/cross-provider-overview";
import { COMPLIANCE_TAB } from "./_types";
import {
CrossAccountOverviewSkeleton,
CrossProviderOverviewSkeleton,
} from "./_components/multiple-scans-skeleton";
export default async function Compliance({
searchParams,
@@ -41,37 +45,67 @@ export default async function Compliance({
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.
// cross-provider-compliance-overviews endpoint). It is the landing tab in
// Cloud; in OSS its trigger only carries the upsell badge, so Per Scan
// stays active regardless of `?tab=`.
const crossProviderEnabled = isCloud();
const activeTab = crossProviderEnabled
? getComplianceTab(resolvedSearchParams.tab)
? getComplianceTab(resolvedSearchParams.tab, resolvedSearchParams.scanId)
: 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) {
// The tour's anchors (search, framework cards) only exist on Single Scan,
// so replaying it from here navigates there — and with no scan to render
// those anchors never mount. Fall back to the scan flow instead, matching
// the Per Scan branch below. Fail-open: a failed fetch assumes scans exist.
const scansByState = await getScansByState();
const hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.length > 0
: true;
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={{ flowId: "view-compliance" }}
onboardingAction={
hasCompletedScan
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}
}
>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
perScanContent={null}
crossProviderContent={
<Suspense
key={`cross-provider-${searchParamsKey}`}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<CrossProviderOverview searchParams={resolvedSearchParams} />
</Suspense>
// gap-6 = the app-wide 24px below a filter row (Findings and the
// Single Scan tab both use mb-6), so filters→"Across provider
// types" and cards→"Across providers" read as one rhythm.
<div className="flex flex-col gap-6">
<Suspense
key={`cross-provider-${searchParamsKey}`}
fallback={<CrossProviderOverviewSkeleton />}
>
<CrossProviderOverview searchParams={resolvedSearchParams} />
</Suspense>
{/* Regular per-provider frameworks viewable across accounts.
Its fallback mirrors the provider groups while this island
loads independently from the universal frameworks above. */}
<Suspense
key={`cross-account-${searchParamsKey}`}
fallback={<CrossAccountOverviewSkeleton />}
>
<CrossAccountOverviewSection
searchParams={resolvedSearchParams}
/>
</Suspense>
</div>
}
/>
</ContentLayout>