mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): add cross-account compliance view (#12086)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
1460f7b188
commit
0d4a21b5a4
@@ -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'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'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,
|
||||
});
|
||||
|
||||
@@ -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 />");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user