mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +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
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Cross-Provider Compliance'
|
||||
sidebarTitle: 'Cross-Provider Compliance'
|
||||
description: 'Aggregate a single universal compliance framework across every connected provider in Prowler Cloud, review a consolidated roll-up and per-provider breakdown, and download a combined PDF report.'
|
||||
description: 'Aggregate a universal compliance framework across compatible providers with completed scans, or a single-provider framework across every provider of one type, review consolidated roll-ups and per-provider breakdowns, and download combined PDF reports.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.34.0" />
|
||||
|
||||
Cross-Provider Compliance consolidates a single **universal compliance framework** across all of your connected providers into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and every other provider as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF.
|
||||
Cross-Provider Compliance consolidates a single **universal compliance framework** across your compatible providers with completed scans into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and other supported providers as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
@@ -50,20 +50,25 @@ The catalog grows as new universal frameworks ship in Prowler. Browse the full c
|
||||
<Step title="Open the Compliance section">
|
||||
Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation.
|
||||
</Step>
|
||||
<Step title="Switch to the Cross-provider tab">
|
||||
At the top of the Compliance page, select the **Cross-provider** tab. The **Per Scan** tab (the default) keeps the single-provider compliance experience unchanged.
|
||||
<Step title="Switch to the Multiple Scans tab">
|
||||
At the top of the Compliance page, select the **Multiple Scans** tab. The **Single Scan** tab (the default) keeps the single-provider compliance experience unchanged.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-tab.png" alt="Compliance page showing the Per Scan and Cross-provider tabs, with the Cross-provider tab highlighted" width="900" />
|
||||
<img src="/images/compliance/prowler-app-cross-provider-tab.png" alt="Compliance page showing the Single Scan and Multiple Scans tabs, with the Multiple Scans tab highlighted" width="900" />
|
||||
|
||||
<Note>
|
||||
Cross-Provider Compliance requires at least one completed scan for a compatible provider. If no compatible provider has finished a scan yet, the page shows a notice prompting you to launch or wait for a scan to complete.
|
||||
The **Across provider types** section requires at least one completed scan for a provider compatible with a universal framework. If none is available, that section shows a notice prompting you to launch or wait for a scan to complete. The **Across providers** section can still list single-provider frameworks when its own account and scan requirements are met.
|
||||
</Note>
|
||||
|
||||
## Exploring the Overview
|
||||
|
||||
The overview presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across:
|
||||
|
||||
* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Compliance experience described in the rest of this guide.
|
||||
* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every connected provider of the same type. See [Aggregating a Single-Provider Framework Across Providers](#aggregating-a-single-provider-framework-across-providers).
|
||||
|
||||
The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-overview.png" alt="Cross-Provider Compliance overview showing the provider filters and the framework grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed/manual counts" width="900" />
|
||||
|
||||
@@ -193,7 +198,7 @@ Because the report aggregates many scans, it is generated **asynchronously**:
|
||||
A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when:
|
||||
|
||||
* You apply a filter combination that has never been reported before, or
|
||||
* A contributing provider has run a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one.
|
||||
* A contributing provider has completed a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer completed scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one.
|
||||
|
||||
"Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan.
|
||||
|
||||
@@ -201,6 +206,27 @@ A report already generated for a given set of filters does not need to be genera
|
||||
The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting.
|
||||
</Note>
|
||||
|
||||
## Aggregating a Single-Provider Framework Across Providers
|
||||
|
||||
Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant are all of my AWS accounts against CIS AWS, together?"**
|
||||
|
||||
For every provider type with **two or more connected providers**, the section shows a collapsible group headed by the provider type and its counts (frameworks available and providers connected). Expanding a group reveals one card per single-provider framework available for that type, keeping the section compact even when several multi-provider types are connected. Selecting a card opens a detail page with the same layout as the cross-provider detail, with the column axis swapped from provider type to provider:
|
||||
|
||||
* **One column per provider:** Prowler auto-selects the latest completed scan of every provider of that type you are allowed to see, and each requirement shows a status chip per provider (labeled with its alias or account ID).
|
||||
* **Account Coverage:** The coverage card ranks each provider's individual posture so the weakest account is visible at a glance.
|
||||
* **Filters:** Narrow the aggregation to specific providers or provider groups. The provider type is fixed by the framework, so there is no type filter here.
|
||||
* **Findings drill-down:** Expanding a requirement queries the findings of every contributing provider's scan and merges them into a single table.
|
||||
|
||||
The roll-up rules are identical to the cross-provider ones (**FAIL > PASS > MANUAL**, only providers that contributed a result count), applied per provider instead of per provider type. Scan selection, RBAC scoping, and staleness behavior also match: the view always reflects each provider's most recent completed scan, scoped to your role's visibility.
|
||||
|
||||
<Note>
|
||||
Provider types with a single connected provider are not listed — with one provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
### Cross-Account PDF Report
|
||||
|
||||
The detail page's **Report** button produces a single combined PDF across every contributing provider of the type, with the same asynchronous generation, background notification, and **Download latest** reuse flow as the [cross-provider report](#downloading-the-combined-pdf-report). The report is tied to the exact resolved scan set, so a new completed scan in any contributing provider makes the previous report stale and Prowler offers to generate an up-to-date one.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Compliance](/user-guide/compliance/tutorials/compliance)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`/compliance` now lands on the Multiple Scans tab; links carrying a `scanId` keep opening Single Scan
|
||||
@@ -0,0 +1 @@
|
||||
Compliance tab naming: "Per Scan" is now "Single Scan" and "Cross-Provider" is now "Multiple Scans", with matching "Across provider types" and "Across providers" section headers explaining each aggregation axis
|
||||
@@ -0,0 +1 @@
|
||||
Cross-account compliance view in the Multiple Scans tab: an "Across providers" section listing single-provider frameworks aggregatable across every account of the same provider type, with a per-account detail, findings drill-down and combined PDF report (Prowler Cloud only)
|
||||
+12
-4
@@ -1,5 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import { FindingStatus, StatusFindingBadge } from "@/components/shadcn/table";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
|
||||
interface ComplianceAccordionRequirementTitleProps {
|
||||
@@ -7,6 +13,7 @@ interface ComplianceAccordionRequirementTitleProps {
|
||||
name: string;
|
||||
status: FindingStatus;
|
||||
invalidConfig?: boolean;
|
||||
statusContent?: ReactNode;
|
||||
}
|
||||
|
||||
export const ComplianceAccordionRequirementTitle = ({
|
||||
@@ -14,19 +21,20 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
name,
|
||||
status,
|
||||
invalidConfig = false,
|
||||
statusContent,
|
||||
}: ComplianceAccordionRequirementTitleProps) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex w-5/6 items-center gap-2">
|
||||
{type && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
<Badge variant="tag" size="sm">
|
||||
{type}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
<span>{name}</span>
|
||||
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
{statusContent ?? <StatusFindingBadge status={status} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3 pr-9">
|
||||
{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">
|
||||
<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={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
|
||||
@@ -86,7 +86,7 @@ export const ComplianceHeader = ({
|
||||
src={logoPath}
|
||||
alt={`${complianceTitle} logo`}
|
||||
fill
|
||||
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
|
||||
className="border-border-neutral-tertiary rounded-lg border bg-slate-50 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,12 +192,37 @@ export function getStandaloneFindingColumns({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const rawAlias = getProviderData(row, "alias");
|
||||
const rawUid = getProviderData(row, "uid");
|
||||
// getProviderData's union includes the provider's connection object;
|
||||
// only string attribute values are renderable here.
|
||||
const alias = typeof rawAlias === "string" ? rawAlias : "-";
|
||||
const uid = typeof rawUid === "string" ? rawUid : "-";
|
||||
// The icon alone cannot tell accounts of the same type apart — the
|
||||
// cross-provider/cross-account drill-downs merge findings from
|
||||
// several accounts into this one table, so each row carries its
|
||||
// account label (alias when set, uid otherwise).
|
||||
const label = alias !== "-" ? alias : uid;
|
||||
|
||||
return (
|
||||
<ProviderIconCell
|
||||
provider={provider as ProviderType}
|
||||
className="size-8"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconCell
|
||||
provider={provider as ProviderType}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
{label !== "-" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-secondary max-w-[110px] truncate text-xs">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{alias !== "-" && uid !== "-" ? `${alias} (${uid})` : label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
|
||||
@@ -18,6 +18,11 @@ const navigationMocks = vi.hoisted(() => ({
|
||||
|
||||
const requestReplayMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
/** `replayFlow` reads the live query from `window` (not `useSearchParams`), so
|
||||
* the CSR bailout stays inside the Suspense boundary NavbarClient renders. */
|
||||
const setLocationSearch = (search: string) =>
|
||||
window.history.replaceState(null, "", `/${search}`);
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationMocks.pathname,
|
||||
useRouter: () => ({ push: navigationMocks.push }),
|
||||
@@ -65,6 +70,7 @@ describe("NavbarClient", () => {
|
||||
navigationMocks.push.mockClear();
|
||||
requestReplayMock.mockClear();
|
||||
navigationMocks.searchParams = new URLSearchParams();
|
||||
setLocationSearch("");
|
||||
window.localStorage.clear();
|
||||
// Replay icon is Cloud-only.
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
@@ -146,8 +152,34 @@ describe("NavbarClient", () => {
|
||||
|
||||
it("starts the replay in-memory (no navigation) when already on the flow's route", async () => {
|
||||
// Given the user is already on the flow's page
|
||||
navigationMocks.pathname = "/findings";
|
||||
usePageReadyStore.setState({ readyPath: "/findings" });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then the replay is requested via the in-memory store — no router.push, so no
|
||||
// `?onboarding=` URL param and no Next.js RSC refetch of the page.
|
||||
expect(requestReplayMock).toHaveBeenCalledWith("explore-findings");
|
||||
expect(navigationMocks.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates when the flow pins a tab the current route does not carry", async () => {
|
||||
// Given the user is on the bare /compliance route (Multiple Scans), while
|
||||
// the tour anchors live on the Single Scan tab.
|
||||
navigationMocks.pathname = "/compliance";
|
||||
navigationMocks.searchParams = new URLSearchParams("scanId=scan-1&foo=bar");
|
||||
setLocationSearch("");
|
||||
usePageReadyStore.setState({ readyPath: "/compliance" });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -164,8 +196,37 @@ describe("NavbarClient", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Then the replay is requested via the in-memory store — no router.push, so no
|
||||
// `?onboarding=` URL param and no Next.js RSC refetch of the page.
|
||||
// Then a real navigation switches tabs before the tour starts.
|
||||
expect(requestReplayMock).not.toHaveBeenCalled();
|
||||
expect(navigationMocks.push).toHaveBeenCalledWith(
|
||||
"/compliance?tab=per-scan&onboarding=view-compliance",
|
||||
);
|
||||
});
|
||||
|
||||
it("replays in-memory when the pinned tab is already active, keeping the selected scan", async () => {
|
||||
// Given the user is on Single Scan with a scan and a filter picked — a
|
||||
// navigation here would reset both, so the pinned tab must not force one.
|
||||
navigationMocks.pathname = "/compliance";
|
||||
setLocationSearch(
|
||||
"?tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1",
|
||||
);
|
||||
usePageReadyStore.setState({ readyPath: "/compliance" });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Compliance"
|
||||
onboardingAction={{ flowId: "view-compliance" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: check compliance/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(requestReplayMock).toHaveBeenCalledWith("view-compliance");
|
||||
expect(navigationMocks.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { SidePanelTrigger } from "@/components/side-panel";
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { getFlowById, isOnFlowRoute } from "@/lib/onboarding";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -67,8 +67,13 @@ export function NavbarClient({
|
||||
if (!flow) return;
|
||||
|
||||
// Already on the flow's page: start the replay via an in-memory signal so the
|
||||
// URL never changes and Next.js never refetches the (often heavy) page.
|
||||
if (flow.route === pathname) {
|
||||
// URL never changes and Next.js never refetches the (often heavy) page — and
|
||||
// the params the user built up (selected scan, filters) survive.
|
||||
// The query is read from `window` rather than `useSearchParams()` on purpose:
|
||||
// the hook would hoist the CSR bailout to this component, outside the Suspense
|
||||
// boundary below that exists to contain it. This runs on click, client-only.
|
||||
const currentQuery = new URLSearchParams(window.location.search);
|
||||
if (isOnFlowRoute(flow.route, pathname, currentQuery)) {
|
||||
requestReplay(flow.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,7 +282,9 @@ describe("ScanJobsRowActions", () => {
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith("/compliance?scanId=scan-1");
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/compliance?tab=per-scan&scanId=scan-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("renames the completed scan report download action", async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
import { toLocalDateString } from "@/lib/date-utils";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
@@ -99,7 +100,7 @@ export function ScanJobsRowActions({
|
||||
|
||||
const openCompliance = () => {
|
||||
if (!isCompleted) return;
|
||||
router.push(`/compliance?scanId=${scan.id}`);
|
||||
router.push(buildPerScanComplianceHref({ scanId: scan.id }));
|
||||
};
|
||||
|
||||
const openErrorDetails = async () => {
|
||||
|
||||
@@ -138,7 +138,10 @@ export const Accordion = ({
|
||||
isCompact ? "py-2" : "py-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
{/* min-w-0 lets flex children of the title shrink/truncate;
|
||||
without it a long requirement title forces the row past
|
||||
the trigger's rounded border (flex min-width:auto). */}
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-sm">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="text-text-neutral-tertiary text-xs">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CustomBreadcrumbItem {
|
||||
@@ -101,8 +102,10 @@ export function BreadcrumbNavigation({
|
||||
|
||||
const buildNavigationUrl = (path: string) => {
|
||||
const paramValue = searchParams.get(paramToPreserve);
|
||||
// A preserved scan only makes sense on Single Scan, which is no longer
|
||||
// the landing tab: the bare route would drop it for Multiple Scans.
|
||||
if (path === "/compliance" && paramValue) {
|
||||
return `/compliance?${paramToPreserve}=${paramValue}`;
|
||||
return buildPerScanComplianceHref({ [paramToPreserve]: paramValue });
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,10 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
xl: "p-8",
|
||||
none: "p-0",
|
||||
},
|
||||
interactive: {
|
||||
true: "cursor-pointer transition-shadow hover:shadow-md",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
@@ -57,6 +61,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
padding: "default",
|
||||
interactive: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,11 +69,17 @@ interface CardProps
|
||||
extends React.ComponentProps<"div">,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
function Card({ className, variant, padding, ...props }: CardProps) {
|
||||
function Card({
|
||||
className,
|
||||
variant,
|
||||
padding,
|
||||
interactive,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(cardVariants({ variant, padding }), className)}
|
||||
className={cn(cardVariants({ variant, padding, interactive }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,12 +7,21 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressProps extends ComponentProps<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string;
|
||||
variant?: "default" | "success" | "warning" | "danger";
|
||||
}
|
||||
|
||||
const indicatorVariants = {
|
||||
default: "bg-button-primary",
|
||||
success: "bg-bg-pass",
|
||||
warning: "bg-bg-warning",
|
||||
danger: "bg-bg-fail",
|
||||
} as const;
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value = 0,
|
||||
indicatorClassName,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const normalizedValue = value ?? 0;
|
||||
@@ -30,7 +39,8 @@ function Progress({
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"bg-button-primary h-full w-full flex-1 transition-all",
|
||||
"h-full w-full flex-1 transition-all",
|
||||
indicatorVariants[variant],
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
|
||||
@@ -5,13 +5,22 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
size?: "auto" | "md";
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
ScrollAreaProps
|
||||
>(({ className, children, size = "auto", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
size === "md" && "h-72",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CROSS_ACCOUNT_PDF_TASK_KIND,
|
||||
crossAccountPdfHandler,
|
||||
} from "@/app/(prowler)/compliance/_lib/cross-account-pdf";
|
||||
import {
|
||||
CROSS_PROVIDER_PDF_TASK_KIND,
|
||||
crossProviderPdfHandler,
|
||||
@@ -16,6 +20,7 @@ import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
|
||||
// this tab. Adding a new watched task kind (integration tests, scan exports,
|
||||
// …) is one line here plus a handler next to the feature that owns it.
|
||||
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
|
||||
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
|
||||
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,6 +83,7 @@ export const CLOUD_UPGRADE_CONTENT = {
|
||||
"Replace separate scan reports with a consolidated compliance view.",
|
||||
benefits: [
|
||||
"Compare framework posture across providers",
|
||||
"Roll up every account in a Provider Group",
|
||||
"Find coverage gaps without switching scans",
|
||||
"Generate a consolidated compliance report",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPerScanComplianceHref } from "./compliance-tab-url";
|
||||
|
||||
describe("buildPerScanComplianceHref", () => {
|
||||
it("pins the Single Scan tab when no extra params are given", () => {
|
||||
expect(buildPerScanComplianceHref()).toBe("/compliance?tab=per-scan");
|
||||
});
|
||||
|
||||
it("keeps the tab first and appends the extra params", () => {
|
||||
expect(buildPerScanComplianceHref({ scanId: "scan-1" })).toBe(
|
||||
"/compliance?tab=per-scan&scanId=scan-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes param values", () => {
|
||||
expect(buildPerScanComplianceHref({ scanId: "scan 1&tab=bogus" })).toBe(
|
||||
"/compliance?tab=per-scan&scanId=scan+1%26tab%3Dbogus",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a caller-supplied tab so the pin cannot be defeated", () => {
|
||||
expect(
|
||||
buildPerScanComplianceHref({ tab: "cross-provider", scanId: "scan-1" }),
|
||||
).toBe("/compliance?tab=per-scan&scanId=scan-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { COMPLIANCE_TAB } from "@/types/compliance";
|
||||
|
||||
/**
|
||||
* Builds a `/compliance` URL pinned to the Single Scan tab.
|
||||
*
|
||||
* Multiple Scans is the default landing tab and owns the bare `/compliance`
|
||||
* route, so every link that targets one concrete scan has to pin Single Scan
|
||||
* explicitly or it lands on the aggregated view instead.
|
||||
*/
|
||||
export function buildPerScanComplianceHref(
|
||||
params?: Record<string, string>,
|
||||
): string {
|
||||
// `tab` is owned by this helper: a caller-supplied one would silently defeat
|
||||
// the pin the function name promises, so it is dropped rather than merged.
|
||||
const extras = new URLSearchParams(params);
|
||||
extras.delete("tab");
|
||||
|
||||
const search = new URLSearchParams({ tab: COMPLIANCE_TAB.PER_SCAN });
|
||||
extras.forEach((value, key) => search.append(key, value));
|
||||
|
||||
return `/compliance?${search.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isOnFlowRoute } from "../flow-route";
|
||||
|
||||
const query = (search: string) => new URLSearchParams(search);
|
||||
|
||||
describe("isOnFlowRoute", () => {
|
||||
it("matches a plain route on the same pathname", () => {
|
||||
expect(isOnFlowRoute("/findings", "/findings", query(""))).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores extra params the user built up", () => {
|
||||
expect(
|
||||
isOnFlowRoute(
|
||||
"/compliance?tab=per-scan",
|
||||
"/compliance",
|
||||
query("tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a different pathname", () => {
|
||||
expect(
|
||||
isOnFlowRoute("/compliance?tab=per-scan", "/findings", query("")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the same pathname when a pinned param is missing", () => {
|
||||
expect(
|
||||
isOnFlowRoute("/compliance?tab=per-scan", "/compliance", query("")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the same pathname when a pinned param has another value", () => {
|
||||
expect(
|
||||
isOnFlowRoute("/scans?tab=active", "/scans", query("tab=scheduled")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires every pinned param, not just the first", () => {
|
||||
expect(
|
||||
isOnFlowRoute(
|
||||
"/scans?tab=active&view=list",
|
||||
"/scans",
|
||||
query("tab=active"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,11 +79,11 @@ describe("onboardingFlows (production registry)", () => {
|
||||
expect(flow?.tour.id).toBe("explore-findings");
|
||||
});
|
||||
|
||||
it("registers the view-compliance flow at order 4 on the compliance route", () => {
|
||||
it("registers the view-compliance flow at order 4 on the single scan tab", () => {
|
||||
const flow = getFlowById("view-compliance", onboardingFlows);
|
||||
expect(flow).toBeDefined();
|
||||
expect(flow?.order).toBe(4);
|
||||
expect(flow?.route).toBe("/compliance");
|
||||
expect(flow?.route).toBe("/compliance?tab=per-scan");
|
||||
expect(flow?.tour.id).toBe("view-compliance");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tells whether the browser is already showing a flow's route.
|
||||
*
|
||||
* A flow route may pin query params (`/compliance?tab=per-scan`,
|
||||
* `/scans?tab=active`) that select the view its anchors live in, so a plain
|
||||
* string compare against `pathname` never matches and the replay degrades into
|
||||
* a full navigation — losing whatever params the user had built up.
|
||||
*
|
||||
* The pinned params are a subset check, not an equality one: extra params the
|
||||
* user picked up (a selected scan, filters) still count as being on the route.
|
||||
*/
|
||||
export function isOnFlowRoute(
|
||||
flowRoute: string,
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
): boolean {
|
||||
const [routePath, routeQuery = ""] = flowRoute.split("?");
|
||||
|
||||
if (routePath !== pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// forEach, not for..of: URLSearchParams is only iterable under
|
||||
// downlevelIteration, which this tsconfig does not enable.
|
||||
let carriesPinnedParams = true;
|
||||
new URLSearchParams(routeQuery).forEach((value, key) => {
|
||||
if (searchParams.get(key) !== value) {
|
||||
carriesPinnedParams = false;
|
||||
}
|
||||
});
|
||||
|
||||
return carriesPinnedParams;
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
export type { GateDecisionInput } from "./gate-decision";
|
||||
export { shouldStartOnboarding } from "./gate-decision";
|
||||
export { isOnFlowRoute } from "./flow-route";
|
||||
export type { OnboardingContext, OnboardingFlow } from "./onboarding-types";
|
||||
export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
import { addProviderTour } from "@/lib/tours/add-provider.tour";
|
||||
import { attackPathsTour } from "@/lib/tours/attack-paths.tour";
|
||||
import { exploreFindingsTour } from "@/lib/tours/explore-findings.tour";
|
||||
@@ -46,7 +47,9 @@ export const onboardingFlows: readonly OnboardingFlow[] = [
|
||||
order: 4,
|
||||
title: "Check compliance",
|
||||
description: "Map your findings to frameworks like CIS.",
|
||||
route: "/compliance",
|
||||
// Land on Single Scan: the framework cards the tour anchors to only
|
||||
// render there, and the bare route now opens Multiple Scans.
|
||||
route: buildPerScanComplianceHref(),
|
||||
tour: viewComplianceTour,
|
||||
dataRequirementHint: SCAN_DATA_HINT,
|
||||
},
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type ViewComplianceTourTarget,
|
||||
} from "../view-compliance.tour";
|
||||
|
||||
// Only these two carry a `data-tour-id` in the UI; welcome step has no target.
|
||||
const ALLOWED_TARGETS = ["frameworks", "search"] as const;
|
||||
const ALLOWED_TARGETS = ["tabs", "frameworks", "search"] as const;
|
||||
|
||||
const definedTargets = (): ViewComplianceTourTarget[] =>
|
||||
viewComplianceTour.steps
|
||||
@@ -23,14 +22,14 @@ describe("viewComplianceTour shape", () => {
|
||||
|
||||
it("declares a positive integer version", () => {
|
||||
expect(Number.isInteger(viewComplianceTour.version)).toBe(true);
|
||||
expect(viewComplianceTour.version).toBeGreaterThan(0);
|
||||
expect(viewComplianceTour.version).toBe(2);
|
||||
});
|
||||
|
||||
it("anchors exactly the search and frameworks steps, in that order", () => {
|
||||
it("introduces the scan modes before search and frameworks", () => {
|
||||
// Search sits above the cards in the DOM; the tour must follow top-to-bottom
|
||||
// so the spotlight never jumps back up the page.
|
||||
const targets = definedTargets();
|
||||
expect(targets).toEqual(["search", "frameworks"]);
|
||||
expect(targets).toEqual(["tabs", "search", "frameworks"]);
|
||||
});
|
||||
|
||||
it("never targets an element outside the allowed anchor set", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
// Const map keeps the union narrow so `useDriverTour` can validate step keys.
|
||||
export const VIEW_COMPLIANCE_TOUR_TARGETS = {
|
||||
TABS: "tabs",
|
||||
FRAMEWORKS: "frameworks",
|
||||
SEARCH: "search",
|
||||
} as const;
|
||||
@@ -16,7 +17,7 @@ export type ViewComplianceTourTarget =
|
||||
|
||||
export const viewComplianceTour = defineTour<ViewComplianceTourTarget>({
|
||||
id: "view-compliance",
|
||||
version: 1,
|
||||
version: 2,
|
||||
coversFiles: [
|
||||
"ui/app/(prowler)/compliance/**",
|
||||
"ui/components/compliance/**",
|
||||
@@ -27,6 +28,14 @@ export const viewComplianceTour = defineTour<ViewComplianceTourTarget>({
|
||||
description:
|
||||
"Compliance maps your findings to frameworks like CIS so you can see where you stand against each standard.",
|
||||
},
|
||||
{
|
||||
target: "tabs",
|
||||
side: TOUR_STEP_SIDES.BOTTOM,
|
||||
align: TOUR_STEP_ALIGNMENTS.START,
|
||||
title: "Choose how to combine scans",
|
||||
description:
|
||||
"Multiple Scans combines results across provider types or across accounts of the same provider type. Single Scan reviews one scan at a time.",
|
||||
},
|
||||
{
|
||||
target: "search",
|
||||
side: TOUR_STEP_SIDES.BOTTOM,
|
||||
|
||||
@@ -10,6 +10,17 @@ export const REQUIREMENT_STATUS = {
|
||||
export type RequirementStatus =
|
||||
(typeof REQUIREMENT_STATUS)[keyof typeof REQUIREMENT_STATUS];
|
||||
|
||||
/** Tabs of the compliance overview page. Multiple Scans is the default
|
||||
* landing tab, so it owns the bare `/compliance` route and Single Scan is
|
||||
* reachable through `?tab=per-scan`. */
|
||||
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 COMPLIANCE_OVERVIEW_TYPE = {
|
||||
OVERVIEW: "compliance-overviews",
|
||||
REQUIREMENTS_STATUS: "compliance-requirements-status",
|
||||
|
||||
Reference in New Issue
Block a user