mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-14 07:24:15 +00:00
feat(ui): add UI changes related with PDF generation
This commit is contained in:
40 files changed
+2609
-184
No files matched your search
@@ -1,8 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import type { ScanBinaryResult } from "@/actions/scans";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
async function getCrossProviderPdfErrorMessage(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
): Promise<string> {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
|
||||
if (contentType.includes("text/html")) {
|
||||
return GENERIC_SERVER_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
const errorData = await response.json().catch(() => null);
|
||||
|
||||
return (
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.errors?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
(response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export const getCompliancesOverview = async ({
|
||||
scanId,
|
||||
region,
|
||||
@@ -34,7 +61,7 @@ export const getCompliancesOverview = async ({
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliances overview:", error);
|
||||
return undefined;
|
||||
@@ -66,7 +93,7 @@ export const getComplianceOverviewMetadataInfo = async ({
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliance overview metadata info:", error);
|
||||
return undefined;
|
||||
@@ -100,7 +127,7 @@ export const getComplianceAttributes = async (
|
||||
return { warming: true as const, status: 503 };
|
||||
}
|
||||
|
||||
return handleApiResponse(response);
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliance attributes:", error);
|
||||
return undefined;
|
||||
@@ -134,7 +161,7 @@ export const getComplianceRequirements = async ({
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliance requirements:", error);
|
||||
return undefined;
|
||||
@@ -155,11 +182,15 @@ export const getCrossProviderComplianceOverview = async ({
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanIds?: string[];
|
||||
providerTypes?: string | string[];
|
||||
providerIds?: string | string[];
|
||||
providerGroups?: string | string[];
|
||||
regions?: string | string[];
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
@@ -179,6 +210,8 @@ export const getCrossProviderComplianceOverview = async ({
|
||||
setParam("filter[scan__in]", scanIds);
|
||||
}
|
||||
setParam("filter[provider_type__in]", providerTypes);
|
||||
setParam("filter[provider_id__in]", providerIds);
|
||||
setParam("filter[provider_groups__in]", providerGroups);
|
||||
setParam("filter[region__in]", regions);
|
||||
|
||||
try {
|
||||
@@ -186,9 +219,267 @@ export const getCrossProviderComplianceOverview = async ({
|
||||
|
||||
if (response.status === 402) return { redirectTo: "/billing" };
|
||||
|
||||
return handleApiResponse(response);
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching cross-provider compliance overview:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger ad-hoc generation of the combined cross-provider compliance PDF.
|
||||
*
|
||||
* Backed by ``POST /cross-provider-compliance-overviews/generate-pdf``. The
|
||||
* PDF itself is built asynchronously by a Celery task and is not ready when
|
||||
* this call returns — it dispatches the job and returns the ``Task`` id so
|
||||
* the caller can poll it (via ``getTask``) and then fetch the finished file
|
||||
* with {@link getCrossProviderCompliancePdf}.
|
||||
*
|
||||
* ``scanIds`` should be the exact scan ids the caller currently has on
|
||||
* screen (``attributes.scan_ids`` from the overview response) so the PDF
|
||||
* matches what the user is looking at instead of re-resolving "latest scan
|
||||
* per provider" a second time, which could race a scan that completes
|
||||
* in between.
|
||||
*
|
||||
* ``providerTypes``/``providerIds``/``providerGroups`` should be the same
|
||||
* ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` values currently applied to the on-screen
|
||||
* cross-provider view (same shape ``getCrossProviderComplianceOverview``
|
||||
* takes) so the generated PDF only includes the providers the user has
|
||||
* filtered down to, instead of silently widening back out to every
|
||||
* compatible provider.
|
||||
*/
|
||||
export const generateCrossProviderCompliancePdf = async ({
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
onlyFailed,
|
||||
includeManual,
|
||||
reportName,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanIds?: string[];
|
||||
providerTypes?: string | string[];
|
||||
providerIds?: string | string[];
|
||||
providerGroups?: string | string[];
|
||||
onlyFailed?: boolean;
|
||||
includeManual?: boolean;
|
||||
/** Optional user-chosen download filename. Sanitized server-side (a
|
||||
* `.pdf` extension is enforced); omit for a unique timestamped default. */
|
||||
reportName?: string;
|
||||
}): Promise<{ taskId: string } | { error: string }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf`,
|
||||
);
|
||||
|
||||
const setParam = (key: string, value?: string | string[]) => {
|
||||
if (!value) return;
|
||||
const serializedValue = Array.isArray(value) ? value.join(",") : value;
|
||||
if (serializedValue.trim().length > 0) {
|
||||
url.searchParams.set(key, serializedValue);
|
||||
}
|
||||
};
|
||||
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
if (scanIds && scanIds.length > 0) {
|
||||
url.searchParams.set("filter[scan__in]", scanIds.join(","));
|
||||
}
|
||||
setParam("filter[provider_type__in]", providerTypes);
|
||||
setParam("filter[provider_id__in]", providerIds);
|
||||
setParam("filter[provider_groups__in]", providerGroups);
|
||||
setParam("report_name", reportName);
|
||||
if (onlyFailed !== undefined) {
|
||||
url.searchParams.set("only_failed", String(onlyFailed));
|
||||
}
|
||||
if (includeManual !== undefined) {
|
||||
url.searchParams.set("include_manual", String(includeManual));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getCrossProviderPdfErrorMessage(
|
||||
response,
|
||||
"Unable to start PDF generation. Contact support if the issue continues.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
throw new Error("Unexpected response starting PDF generation.");
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the finished cross-provider compliance PDF for a task started by
|
||||
* {@link generateCrossProviderCompliancePdf}.
|
||||
*
|
||||
* Backed by ``GET .../generate-pdf/{taskId}/download``, which mirrors the
|
||||
* same 202-pending / 2xx-binary / error-JSON protocol as the per-scan report
|
||||
* endpoints — returns a {@link ScanBinaryResult} so callers can reuse
|
||||
* ``downloadFile`` from ``lib/helper.ts`` unchanged.
|
||||
*/
|
||||
export const getCrossProviderCompliancePdf = async (
|
||||
taskId: string,
|
||||
): Promise<ScanBinaryResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/${taskId}/download`,
|
||||
);
|
||||
|
||||
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 getCrossProviderPdfErrorMessage(
|
||||
response,
|
||||
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") || "";
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = filenameMatch?.[1] || "compliance-report.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) };
|
||||
}
|
||||
};
|
||||
|
||||
/** A previously-generated cross-provider PDF report matching a given set
|
||||
* of filters, ready to download immediately (no polling required). */
|
||||
export interface LatestCrossProviderPdfReport {
|
||||
taskId: string;
|
||||
filename?: string;
|
||||
/** ISO timestamp the report finished generating (``completed_at``), if
|
||||
* the backend provided one. */
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
export type LatestCrossProviderPdfResult =
|
||||
| ({ available: true } & LatestCrossProviderPdfReport)
|
||||
| { available: false }
|
||||
| { error: string };
|
||||
|
||||
/**
|
||||
* Check whether a cross-provider compliance PDF has already been generated
|
||||
* for the given filters, so the UI can offer "Download" immediately instead
|
||||
* of always making the user click "Generate" and wait.
|
||||
*
|
||||
* Backed by ``GET .../generate-pdf/latest``: 200 with a Task body means a
|
||||
* matching, still-current report exists (the backend only matches reports
|
||||
* generated from the exact scan set these filters currently resolve to —
|
||||
* a report goes stale and this starts returning "not available" the moment
|
||||
* any contributing provider completes a new scan); 404 means "not generated
|
||||
* yet" (a normal, expected state — not surfaced as an error).
|
||||
*
|
||||
* Same param shape as {@link generateCrossProviderCompliancePdf} so callers
|
||||
* can pass through the exact same filter values to both.
|
||||
*/
|
||||
export const getLatestCrossProviderCompliancePdf = async ({
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanIds?: string[];
|
||||
providerTypes?: string | string[];
|
||||
providerIds?: string | string[];
|
||||
providerGroups?: string | string[];
|
||||
}): Promise<LatestCrossProviderPdfResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/latest`,
|
||||
);
|
||||
|
||||
const setParam = (key: string, value?: string | string[]) => {
|
||||
if (!value) return;
|
||||
const serializedValue = Array.isArray(value) ? value.join(",") : value;
|
||||
if (serializedValue.trim().length > 0) {
|
||||
url.searchParams.set(key, serializedValue);
|
||||
}
|
||||
};
|
||||
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
if (scanIds && scanIds.length > 0) {
|
||||
url.searchParams.set("filter[scan__in]", scanIds.join(","));
|
||||
}
|
||||
setParam("filter[provider_type__in]", providerTypes);
|
||||
setParam("filter[provider_id__in]", providerIds);
|
||||
setParam("filter[provider_groups__in]", providerGroups);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 404) {
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getCrossProviderPdfErrorMessage(
|
||||
response,
|
||||
"Unable to check for an existing PDF report. Contact support if the issue continues.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
taskId,
|
||||
filename: json?.data?.attributes?.result?.filename,
|
||||
generatedAt: json?.data?.attributes?.completed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
// Logged here because the caller (the compliance detail page) degrades
|
||||
// an ``{error}`` to "no report available" by design — the right UX for
|
||||
// an optional availability check, but without this log a systematically
|
||||
// failing endpoint would be indistinguishable from "never generated".
|
||||
console.error(
|
||||
"Error checking for an existing cross-provider compliance PDF:",
|
||||
error,
|
||||
);
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
@@ -13,7 +13,12 @@ export const getTask = async (taskId: string) => {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
// Must be awaited here, not just returned: ``handleApiResponse`` is
|
||||
// itself async and throws on non-2xx — a bare `return` hands back its
|
||||
// promise without this function's own `try` ever observing a rejection,
|
||||
// so `catch` below never runs and callers get an unhandled rejection
|
||||
// instead of the documented `{error}` shape.
|
||||
return await handleApiResponse(response);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
getComplianceRequirements,
|
||||
getCompliancesOverview,
|
||||
getCrossProviderComplianceOverview,
|
||||
getLatestCrossProviderCompliancePdf,
|
||||
} from "@/actions/compliances";
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScan } from "@/actions/scans";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
@@ -54,6 +57,8 @@ interface ComplianceDetailSearchParams {
|
||||
mode?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[provider_type__in]"?: string;
|
||||
"filter[provider_id__in]"?: string;
|
||||
"filter[provider_groups__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
@@ -71,6 +76,9 @@ export default async function ComplianceDetail({
|
||||
const { complianceId, version, scanId, section, mode } = resolvedSearchParams;
|
||||
const regionFilter = resolvedSearchParams["filter[region__in]"];
|
||||
const providerTypeFilter = resolvedSearchParams["filter[provider_type__in]"];
|
||||
const providerIdFilter = resolvedSearchParams["filter[provider_id__in]"];
|
||||
const providerGroupsFilter =
|
||||
resolvedSearchParams["filter[provider_groups__in]"];
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
|
||||
@@ -85,11 +93,32 @@ export default async function ComplianceDetail({
|
||||
}
|
||||
|
||||
const crossProviderTitle = compliancetitle.split("-").join(" ");
|
||||
const crossProviderResponse = await getCrossProviderComplianceOverview({
|
||||
complianceId,
|
||||
providerTypes: providerTypeFilter,
|
||||
regions: regionFilter,
|
||||
});
|
||||
const [
|
||||
crossProviderResponse,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
latestPdfReport,
|
||||
] = await Promise.all([
|
||||
getCrossProviderComplianceOverview({
|
||||
complianceId,
|
||||
providerTypes: providerTypeFilter,
|
||||
providerIds: providerIdFilter,
|
||||
providerGroups: providerGroupsFilter,
|
||||
regions: regionFilter,
|
||||
}),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
// Independent of the overview fetch above — the backend resolves the
|
||||
// same "latest scan per filtered provider" rule from these same raw
|
||||
// filters, so this can run in parallel instead of waterfalling behind
|
||||
// the overview response just to read its resolved scan_ids.
|
||||
getLatestCrossProviderCompliancePdf({
|
||||
complianceId,
|
||||
providerTypes: providerTypeFilter,
|
||||
providerIds: providerIdFilter,
|
||||
providerGroups: providerGroupsFilter,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!crossProviderResponse || "redirectTo" in crossProviderResponse) {
|
||||
return (
|
||||
@@ -124,9 +153,27 @@ export default async function ComplianceDetail({
|
||||
}
|
||||
|
||||
const headerTitle = crossProviderData.attributes.name || crossProviderTitle;
|
||||
const compatibleProviderTypes = new Set(
|
||||
crossProviderData.attributes.compatible_providers,
|
||||
);
|
||||
const compatibleProviders = (providersData?.data || []).filter((provider) =>
|
||||
compatibleProviderTypes.has(provider.attributes.provider),
|
||||
);
|
||||
return (
|
||||
<ContentLayout title={headerTitle}>
|
||||
<CrossProviderDetail attributes={crossProviderData.attributes} />
|
||||
<CrossProviderDetail
|
||||
attributes={crossProviderData.attributes}
|
||||
providers={compatibleProviders}
|
||||
providerGroups={providerGroupsData?.data || []}
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
latestPdfReport={
|
||||
"available" in latestPdfReport && latestPdfReport.available
|
||||
? latestPdfReport
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScansByState } from "@/actions/scans/scans";
|
||||
import { CrossProviderPdfWatcher } from "@/components/compliance/cross-provider/cross-provider-pdf-watcher";
|
||||
import {
|
||||
OnboardingCheckpointWatcher,
|
||||
OnboardingGate,
|
||||
@@ -105,6 +106,10 @@ export default async function RootLayout({
|
||||
</>
|
||||
)}
|
||||
<MainLayout>{children}</MainLayout>
|
||||
{/* Single mount point so cross-provider PDF generations keep being
|
||||
polled — and their "ready" toast still fires — after the user
|
||||
navigates away from the page they were generated on. */}
|
||||
<CrossProviderPdfWatcher />
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -36,4 +36,81 @@ describe("client accordion content", () => {
|
||||
it("renders a per-provider breakdown table when providers are exposed", () => {
|
||||
expect(source).toContain("Per-Provider Breakdown");
|
||||
});
|
||||
|
||||
describe("scope-aware cache invalidation", () => {
|
||||
// Regression coverage for a real bug: switching the provider-type /
|
||||
// provider-id / region filters on the cross-provider compliance page
|
||||
// re-renders an already-expanded requirement with a *different* scan
|
||||
// scope, but page/pageSize/sort/mutedFilter can stay identical — so a
|
||||
// guard keyed only on those would wrongly treat the new scope as
|
||||
// "already loaded" and keep showing findings from the filtered-out
|
||||
// provider/region.
|
||||
|
||||
it("derives the fetch scope from scan/check identity AND region, not just pagination/sort/mute", () => {
|
||||
expect(source).toMatch(
|
||||
/const scopeSignature = isCrossProvider\s*\n\s*\?\s*JSON\.stringify\(\{\s*scanIdsByProvider,\s*checkIdsByProvider,\s*region\s*\}\)/,
|
||||
);
|
||||
// The per-scan branch's signature must also fold in ``region`` — a
|
||||
// requirement with the same scanId/check_ids but a different region
|
||||
// filter is a different fetch.
|
||||
expect(source).toMatch(
|
||||
/:\s*`\$\{scanId\}\|\$\{\(requirement\.check_ids \|\| \[\]\)\.join\(","\)\}\|\$\{region\}`/,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the scope signature in the already-loaded skip guard", () => {
|
||||
const guardMatch = source.match(
|
||||
/if \(\s*\n([\s\S]*?)\n\s*\) \{\s*\n\s*return;/,
|
||||
);
|
||||
expect(guardMatch, "expected to find the skip-fetch guard").not.toBe(
|
||||
null,
|
||||
);
|
||||
const guardBody = guardMatch![1];
|
||||
expect(guardBody).toContain("loadedScopeRef.current === scopeSignature");
|
||||
// The pre-existing guard members must still be present — this test
|
||||
// should fail if a refactor drops one of them, not just if scope is
|
||||
// missing.
|
||||
expect(guardBody).toContain("loadedPageRef.current === pageNumber");
|
||||
expect(guardBody).toContain("loadedMutedRef.current === mutedFilter");
|
||||
});
|
||||
|
||||
it("only marks the scope as loaded once the fetch actually commits, for both branches", () => {
|
||||
// Marking it eagerly (before the fetch resolves) would let a
|
||||
// superseded in-flight request's cleanup permanently strand the
|
||||
// component at ``findings === null`` — see the neighboring comment
|
||||
// in source for the full race explanation.
|
||||
const commitPattern = /loadedScopeRef\.current = scopeSignature;/g;
|
||||
const commitIndices = Array.from(source.matchAll(commitPattern)).map(
|
||||
(match) => match.index!,
|
||||
);
|
||||
expect(commitIndices).toHaveLength(2); // cross-provider branch + per-scan branch
|
||||
|
||||
for (const index of commitIndices) {
|
||||
// A cancellation check must precede the commit nearby, and nothing
|
||||
// awaits in between — i.e. the ref is only written synchronously
|
||||
// after the fetch has already resolved and the in-flight request
|
||||
// wasn't superseded by a newer one.
|
||||
const window = source.slice(Math.max(0, index - 250), index);
|
||||
const guardIndex = window.search(/if \(cancelled\) return;/);
|
||||
expect(
|
||||
guardIndex,
|
||||
"expected a nearby cancellation check",
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
expect(window.slice(guardIndex)).not.toContain("await ");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-runs the effect when the scope signature changes", () => {
|
||||
// scopeSignature is derived from requirement/scanIdsByProvider/
|
||||
// checkIdsByProvider/region, which are already effect dependencies,
|
||||
// so this mostly guards against someone deleting the dependency
|
||||
// outright under the (wrong) assumption it's redundant.
|
||||
const depsMatch = source.match(/\}, \[([\s\S]*?)\]\);/);
|
||||
expect(
|
||||
depsMatch,
|
||||
"expected to find the effect's dependency array",
|
||||
).not.toBe(null);
|
||||
expect(depsMatch![1]).toContain("scopeSignature");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
@@ -16,10 +17,7 @@ import { StatusFindingBadge } from "@/components/ui/table/status-finding-badge";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import {
|
||||
getProviderBadge,
|
||||
getProviderLabel,
|
||||
} from "@/lib/providers/provider-display";
|
||||
import { getProviderLabel } from "@/lib/providers/provider-display";
|
||||
import {
|
||||
CrossProviderRequirement,
|
||||
Requirement,
|
||||
@@ -64,6 +62,7 @@ export const ClientAccordionContent = ({
|
||||
const loadedPageSizeRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const loadedMutedRef = useRef<string | null>(null);
|
||||
const loadedScopeRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
// Respect the user's muted preference from the URL; default to EXCLUDE
|
||||
@@ -80,6 +79,15 @@ export const ClientAccordionContent = ({
|
||||
const providersBreakdown = xprov.providers;
|
||||
const isCrossProvider =
|
||||
!!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0;
|
||||
// Identifies *what* findings this requirement should show — which scans,
|
||||
// checks and region it's scoped to. Provider-type/account/region filters
|
||||
// on the page narrow the fetch server-side without necessarily changing
|
||||
// page/sort/mute, so those alone aren't enough to tell a stale fetch from
|
||||
// a fresh one: an already-expanded row would otherwise keep showing
|
||||
// findings from providers or regions the user just filtered out.
|
||||
const scopeSignature = isCrossProvider
|
||||
? JSON.stringify({ scanIdsByProvider, checkIdsByProvider, region })
|
||||
: `${scanId}|${(requirement.check_ids || []).join(",")}|${region}`;
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against a slower earlier request resolving after a newer one and
|
||||
@@ -93,16 +101,20 @@ export const ClientAccordionContent = ({
|
||||
loadedPageSizeRef.current === pageSize &&
|
||||
loadedSortRef.current === sort &&
|
||||
loadedMutedRef.current === mutedFilter &&
|
||||
loadedScopeRef.current === scopeSignature &&
|
||||
isExpandedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
// Mark "loaded" for these exact params only once the fetch actually
|
||||
// commits (below, right before each ``setFindings``) — not here. If a
|
||||
// dependency changes (e.g. a sibling re-render gives ``requirement`` a
|
||||
// new identity) while this fetch is in flight, the effect cleanup
|
||||
// flips ``cancelled`` and the commit is skipped; marking the refs
|
||||
// upfront would make the *next* effect run see "already loaded" and
|
||||
// skip re-fetching too, permanently stranding the component with
|
||||
// ``findings`` stuck at ``null``.
|
||||
|
||||
try {
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
@@ -182,6 +194,12 @@ export const ClientAccordionContent = ({
|
||||
},
|
||||
};
|
||||
if (cancelled) return;
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
loadedScopeRef.current = scopeSignature;
|
||||
isExpandedRef.current = true;
|
||||
setFindings(merged);
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +220,12 @@ export const ClientAccordionContent = ({
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
loadedScopeRef.current = scopeSignature;
|
||||
isExpandedRef.current = true;
|
||||
setFindings(findingsData);
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
@@ -221,6 +245,7 @@ export const ClientAccordionContent = ({
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
scopeSignature,
|
||||
disableFindings,
|
||||
isCrossProvider,
|
||||
scanIdsByProvider,
|
||||
@@ -413,7 +438,6 @@ export const ClientAccordionContent = ({
|
||||
<div className="flex flex-col gap-3 px-3 pb-2">
|
||||
{checkIdsByProviderEntries.map(([providerKey, ids], idx) => {
|
||||
const label = getProviderLabel(providerKey);
|
||||
const Badge = getProviderBadge(providerKey);
|
||||
return (
|
||||
<div
|
||||
key={providerKey}
|
||||
@@ -424,24 +448,32 @@ export const ClientAccordionContent = ({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{Badge ? <Badge size={16} /> : null}
|
||||
<span className="text-text-default text-xs font-semibold tracking-wider uppercase">
|
||||
<ProviderBadgeIcon providerKey={providerKey} size={16} />
|
||||
<span className="text-text-neutral-primary text-xs font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{ids.length} {ids.length === 1 ? "check" : "checks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pl-6">
|
||||
{/* Soft filled chips, no border: bordered pills read as a "wall
|
||||
of boxes" once there are 5-10 of them, but bare text (no
|
||||
fill at all) reads as unstyled/unfinished and a fixed-width
|
||||
grid left ragged gaps for the shorter ids. A tinted
|
||||
background gives each check enough visual weight to look
|
||||
intentional while staying quiet — flex-wrap sizes every
|
||||
chip to its own content so nothing is cramped or stranded
|
||||
in dead space regardless of how long the check id is. */}
|
||||
<ul className="flex flex-wrap gap-1.5 pl-6">
|
||||
{ids.map((id) => (
|
||||
<span
|
||||
<li
|
||||
key={id}
|
||||
className="border-border-neutral-secondary inline-block rounded border bg-gray-50 px-2 py-0.5 font-mono text-[11px] text-gray-700 dark:bg-gray-900/50 dark:text-gray-200"
|
||||
className="bg-default-100 hover:bg-default-200 text-text-neutral-secondary dark:bg-default-100/10 dark:hover:bg-default-100/20 rounded-md px-2 py-1 font-mono text-[11px] transition-colors"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
+26
-12
@@ -1,10 +1,8 @@
|
||||
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import {
|
||||
getProviderBadge,
|
||||
getProviderLabel,
|
||||
} from "@/lib/providers/provider-display";
|
||||
import { getProviderLabel } from "@/lib/providers/provider-display";
|
||||
import { RequirementStatus } from "@/types/compliance";
|
||||
|
||||
interface ComplianceAccordionRequirementTitleProps {
|
||||
@@ -44,14 +42,17 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
<span className="text-text-neutral-primary truncate">{name}</span>
|
||||
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* The accordion trigger this title lives in applies ``hover:underline``
|
||||
to all its text — fine for the plain requirement name, but it paints
|
||||
straight through the provider chips and status pill below. Opt this
|
||||
whole cluster out so hovering the row never underlines a badge. */}
|
||||
<div className="flex flex-wrap items-center gap-2 no-underline">
|
||||
{providerEntries.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{providerEntries.map(([providerKey, providerStatus]) => {
|
||||
const Badge = getProviderBadge(providerKey);
|
||||
const label = getProviderLabel(providerKey);
|
||||
return (
|
||||
<span
|
||||
@@ -59,10 +60,8 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
className="border-border-neutral-secondary inline-flex items-center gap-1 rounded border px-1.5 py-0.5"
|
||||
title={`${label}: ${providerStatus}`}
|
||||
>
|
||||
{Badge ? <Badge size={12} /> : null}
|
||||
<span className="text-[10px] font-semibold uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<ProviderBadgeIcon providerKey={providerKey} size={12} />
|
||||
<span className="text-[10px] font-semibold">{label}</span>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
STATUS_DOT_CLASS_BY_STATUS[providerStatus] ??
|
||||
@@ -75,7 +74,22 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<StatusFindingBadge status={status} />
|
||||
{providerEntries.length > 0 ? (
|
||||
// Fixed width, not just the badge's own ``min-w`` — "Manual" is
|
||||
// visibly wider than "Pass"/"Fail", and this cluster is right-
|
||||
// justified, so a variable-width badge shifts the provider chips
|
||||
// left/right depending on which status happens to render. Locking
|
||||
// the slot width keeps the chips at the same x across every row.
|
||||
// Scoped to cross-provider mode only (``providerEntries.length``)
|
||||
// — every other framework has no chips to keep aligned, so it
|
||||
// keeps the badge at its natural width instead of losing space
|
||||
// to an empty fixed slot.
|
||||
<div className="flex w-[72px] shrink-0 justify-end">
|
||||
<StatusFindingBadge status={status} />
|
||||
</div>
|
||||
) : (
|
||||
<StatusFindingBadge status={status} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,4 +39,54 @@ describe("CompliancePageTabs", () => {
|
||||
expect(tabsSource).toContain("perScanContent");
|
||||
expect(tabsSource).toContain("crossProviderContent");
|
||||
});
|
||||
|
||||
describe("Cross-Provider tab trigger structure", () => {
|
||||
// The active-tab underline sizes itself off ``:not(:first-child)`` /
|
||||
// ``:last-child`` selectors keyed on the trigger's OWN position among
|
||||
// its siblings (see components/shadcn/tabs/tabs.tsx). Wrapping the
|
||||
// trigger in an extra element breaks that and silently drops both the
|
||||
// inter-tab padding AND the underline's left inset, which previously
|
||||
// produced a visibly misaligned (then, when only half-fixed, a
|
||||
// vanished) active indicator on this tab alone. The fix: only pay that
|
||||
// wrapper tax on the OSS/disabled branch where it's structurally
|
||||
// required (tooltip-on-disabled + upsell badge) — the common, enabled
|
||||
// case renders a plain trigger exactly like every other tab in the app
|
||||
// (see app/(prowler)/providers/provider-page-tabs.tsx) and needs no
|
||||
// compensating classes at all.
|
||||
const ternaryMatch = tabsSource.match(
|
||||
/\{crossProviderEnabled \? \(([\s\S]*?)\) : \(([\s\S]*?)\)\}/,
|
||||
);
|
||||
|
||||
it("finds the enabled/disabled branches", () => {
|
||||
expect(
|
||||
ternaryMatch,
|
||||
"expected to find the crossProviderEnabled ternary",
|
||||
).not.toBe(null);
|
||||
});
|
||||
|
||||
const enabledBranch = ternaryMatch?.[1] ?? "";
|
||||
const disabledBranch = ternaryMatch?.[2] ?? "";
|
||||
|
||||
it("renders a plain, unwrapped trigger when the tab is enabled — same shape as every other tab", () => {
|
||||
expect(enabledBranch).toContain("<TabsTrigger");
|
||||
expect(enabledBranch).not.toContain("<span");
|
||||
// No manual padding/underline-inset compensation needed: this branch
|
||||
// is a direct child of TabsList, so the shared component's own
|
||||
// ``:not(:first-child)``/``:last-child`` rules apply automatically.
|
||||
expect(enabledBranch).not.toContain("className=");
|
||||
});
|
||||
|
||||
it("keeps the compensating padding and underline inset on the disabled/OSS branch", () => {
|
||||
expect(disabledBranch).toContain("<span");
|
||||
const triggerMatch = disabledBranch.match(
|
||||
/<TabsTrigger[\s\S]*?className="([^"]*)"/,
|
||||
);
|
||||
expect(
|
||||
triggerMatch,
|
||||
"expected the disabled TabsTrigger to carry a className",
|
||||
).not.toBe(null);
|
||||
expect(triggerMatch![1]).toContain("pl-4");
|
||||
expect(triggerMatch![1]).toContain("after:left-4");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -100,25 +100,42 @@ export const CompliancePageTabs = ({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* The wrapper span (not the disabled TabsTrigger) is the hover
|
||||
target so the tooltip still fires when the tab is disabled in
|
||||
OSS. The span makes the trigger the first child of its own
|
||||
element, which drops the TabsList's inter-tab ``pl-4`` and
|
||||
keeps a trailing ``border-r``; restore the left padding so the
|
||||
label clears the Per-Scan divider and drop the divider so the
|
||||
cloud badge isn't crammed against it. */}
|
||||
<span className="inline-flex items-center">
|
||||
<TabsTrigger
|
||||
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
|
||||
disabled={!crossProviderEnabled}
|
||||
className="border-r-0 pl-4"
|
||||
>
|
||||
{crossProviderEnabled ? (
|
||||
// Plain trigger, direct child of TabsList — same shape as
|
||||
// "Per Scan" and as ProviderPageTabs' tabs. shadcn's active
|
||||
// underline sizes itself off ``:not(:first-child)`` /
|
||||
// ``:last-child`` selectors on the trigger's OWN position
|
||||
// among its siblings; wrapping it in an extra element (as
|
||||
// the disabled branch below has to, for the tooltip-on-
|
||||
// disabled + badge requirements) breaks that and requires
|
||||
// hand-restoring every dropped padding/inset rule. Skipping
|
||||
// the wrapper whenever it isn't structurally necessary
|
||||
// keeps the common (enabled) case correct for free.
|
||||
<TabsTrigger value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}>
|
||||
Cross-Provider
|
||||
</TabsTrigger>
|
||||
{!crossProviderEnabled && (
|
||||
) : (
|
||||
// Disabled in OSS: the wrapper span (not the disabled
|
||||
// TabsTrigger) is the hover target so the tooltip still
|
||||
// fires, and it also hosts the upsell badge next to the
|
||||
// label. That makes the trigger the first/only child of the
|
||||
// span instead of TabsList, dropping the
|
||||
// ``[&:not(:first-child)]`` padding and the matching
|
||||
// ``[&:not(:first-child)]:after:left-4`` underline inset —
|
||||
// restore both by hand or the (invisible, disabled) active
|
||||
// underline would sit misaligned if this tab were ever
|
||||
// reachable.
|
||||
<span className="inline-flex items-center">
|
||||
<TabsTrigger
|
||||
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
|
||||
disabled
|
||||
className="border-r-0 pl-4 after:left-4"
|
||||
>
|
||||
Cross-Provider
|
||||
</TabsTrigger>
|
||||
<CloudFeatureBadgeLink size="sm" className="ml-3" />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{crossProviderEnabled
|
||||
|
||||
@@ -34,4 +34,30 @@ describe("CrossProviderCard", () => {
|
||||
expect(source).toContain("compatibleProviders");
|
||||
expect(source).toContain("ProviderChip");
|
||||
});
|
||||
|
||||
it("renders each provider as a compact icon, not a full uppercase-label pill", () => {
|
||||
// Regression guard: frameworks can declare 15+ compatible providers (CIS
|
||||
// Controls 8.1 has 18) — one full-width uppercase-label pill per
|
||||
// provider used to wrap across 4-5 rows, dwarfing every other card in
|
||||
// the same grid. The fix reuses the same compact icon-square language
|
||||
// as the per-domain provider heatmap on the detail page
|
||||
// (CrossProviderDomainTitle) instead of spelling out each provider name.
|
||||
expect(source).toContain("ProviderBadgeIcon");
|
||||
// ``{providerKey}`` as JSX *text content* (old pill label) vs. as a prop
|
||||
// value (``providerKey={providerKey}``, still expected) — only the
|
||||
// former is the regression this guards against.
|
||||
expect(source).not.toMatch(/>\s*\{providerKey\}\s*</);
|
||||
});
|
||||
|
||||
it("links the info button next to the title to the framework's Prowler Hub page", () => {
|
||||
expect(source).toContain("getProwlerHubComplianceUrl(complianceId)");
|
||||
expect(source).toContain('target="_blank"');
|
||||
});
|
||||
|
||||
it("stops the info button's click from also triggering the card's own drill-down navigation", () => {
|
||||
// Regression guard: the whole card is a click target (onClick={navigateToDetail}).
|
||||
// Without stopPropagation, opening the Hub link would also navigate the
|
||||
// card to the detail page underneath the new tab.
|
||||
expect(source).toContain("e.stopPropagation()");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
@@ -12,10 +15,12 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { getProviderLabel } from "@/lib/providers/provider-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CrossProviderCardProps {
|
||||
@@ -52,25 +57,31 @@ interface ProviderChipProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only chip, not a text pill: universal frameworks can declare 15+
|
||||
* compatible providers (CIS Controls 8.1 alone has 18), and one full-width
|
||||
* uppercase-label pill per provider used to wrap across 4-5 rows on a card
|
||||
* that also has to fit a title, score bar and description — every other
|
||||
* card in the same grid stayed a fraction of that height. Same compact
|
||||
* heatmap-cell language as the per-domain provider row on the detail page
|
||||
* (``CrossProviderDomainTitle``): one row of small icon squares regardless
|
||||
* of provider count, with the full name available on hover/focus.
|
||||
*/
|
||||
const ProviderChip = ({ providerKey, active }: ProviderChipProps) => {
|
||||
const Icon = active ? Check : Circle;
|
||||
const label = getProviderLabel(providerKey);
|
||||
const tooltip = active ? `${label}: scan available` : `${label}: no scan yet`;
|
||||
return (
|
||||
<span
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5",
|
||||
"text-[10px] font-semibold tracking-wide uppercase",
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-md border",
|
||||
active
|
||||
? "border-bg-pass/40 bg-bg-pass/10 text-bg-pass"
|
||||
: "border-border-neutral-secondary text-text-neutral-secondary",
|
||||
? "border-bg-pass/40 bg-bg-pass/10"
|
||||
: "border-border-neutral-secondary opacity-40",
|
||||
)}
|
||||
title={
|
||||
active
|
||||
? `${providerKey.toUpperCase()}: scan available`
|
||||
: `${providerKey.toUpperCase()}: no scan yet`
|
||||
}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" strokeWidth={active ? 3 : 2} />
|
||||
{providerKey}
|
||||
<ProviderBadgeIcon providerKey={providerKey} size={14} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -146,18 +157,40 @@ export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="text-small truncate leading-5 font-bold">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="text-small min-w-0 truncate leading-5 font-bold">
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="bare" size="icon-xs" asChild>
|
||||
<Link
|
||||
href={getProwlerHubComplianceUrl(complianceId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
prefetch={false}
|
||||
aria-label="View on Prowler Hub"
|
||||
// The card's own onClick navigates to the detail
|
||||
// page — without this, clicking the info button
|
||||
// would open the Hub tab AND drill into the card.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View on Prowler Hub</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{description && (
|
||||
<small className="text-text-neutral-secondary mt-0.5 line-clamp-2 text-xs">
|
||||
{description}
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
|
||||
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { CrossProviderExplorerCard } from "./cross-provider-explorer-card";
|
||||
import { CrossProviderFilters } from "./cross-provider-filters";
|
||||
import { CrossProviderHeader } from "./cross-provider-header";
|
||||
|
||||
interface CrossProviderDetailClientProps {
|
||||
attributes: CrossProviderComplianceOverviewAttributes;
|
||||
providers: ProviderProps[];
|
||||
providerGroups: ProviderGroup[];
|
||||
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` values from the URL — threaded down to
|
||||
* the "Generate PDF" button so it respects the same narrowing as this
|
||||
* already-filtered ``attributes`` payload. */
|
||||
providerTypeFilter?: string;
|
||||
providerIdFilter?: string;
|
||||
providerGroupsFilter?: string;
|
||||
latestPdfReport: LatestCrossProviderPdfReport | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +47,12 @@ interface CrossProviderDetailClientProps {
|
||||
*/
|
||||
export const CrossProviderDetailClient = ({
|
||||
attributes,
|
||||
providers,
|
||||
providerGroups,
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderDetailClientProps) => {
|
||||
const insights = useMemo(
|
||||
() => computeCrossProviderInsights(attributes),
|
||||
@@ -74,11 +94,17 @@ export const CrossProviderDetailClient = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CrossProviderFilters
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
|
||||
<CrossProviderHeader
|
||||
framework={attributes.framework}
|
||||
name={attributes.name}
|
||||
version={attributes.version}
|
||||
description={attributes.description}
|
||||
complianceId={attributes.compliance_id}
|
||||
insights={insights}
|
||||
onDomainSelect={handleDomainSelect}
|
||||
/>
|
||||
@@ -88,6 +114,10 @@ export const CrossProviderDetailClient = ({
|
||||
attributes={attributes}
|
||||
insights={insights}
|
||||
forcedExpandedSectionKey={forcedExpandedSectionKey}
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { CrossProviderDetailClient } from "./cross-provider-detail-client";
|
||||
|
||||
interface CrossProviderDetailProps {
|
||||
attributes: CrossProviderComplianceOverviewAttributes;
|
||||
/** Provider accounts of a type compatible with this framework (not
|
||||
* filtered by connection status) — feeds the provider type / account
|
||||
* filter controls. */
|
||||
providers: ProviderProps[];
|
||||
/** Every provider group in the tenant (not narrowed by framework
|
||||
* compatibility — groups can span multiple provider types, same as
|
||||
* every other provider-group filter in the app). */
|
||||
providerGroups: ProviderGroup[];
|
||||
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` values from the URL — threaded down to
|
||||
* the "Generate PDF" button. */
|
||||
providerTypeFilter?: string;
|
||||
providerIdFilter?: string;
|
||||
providerGroupsFilter?: string;
|
||||
/** A previously-generated PDF matching these exact filters, resolved
|
||||
* server-side alongside ``attributes`` — ``null`` means none exists yet
|
||||
* (or it went stale) for the current filters, so the "Generate PDF"
|
||||
* button shows "Generate" instead of "Download". */
|
||||
latestPdfReport: LatestCrossProviderPdfReport | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,6 +39,22 @@ interface CrossProviderDetailProps {
|
||||
*/
|
||||
export const CrossProviderDetail = ({
|
||||
attributes,
|
||||
providers,
|
||||
providerGroups,
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderDetailProps) => {
|
||||
return <CrossProviderDetailClient attributes={attributes} />;
|
||||
return (
|
||||
<CrossProviderDetailClient
|
||||
attributes={attributes}
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DomainStats } from "@/lib/compliance/cross-provider-insights";
|
||||
|
||||
import { CrossProviderDomainTitle } from "./cross-provider-domain-title";
|
||||
|
||||
const makeStats = (overrides: Partial<DomainStats> = {}): DomainStats => ({
|
||||
name: "1. Inventory and Control of Enterprise Assets",
|
||||
total: 5,
|
||||
pass: 1,
|
||||
fail: 2,
|
||||
manual: 2,
|
||||
byProvider: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("CrossProviderDomainTitle", () => {
|
||||
it("reserves a fixed width for the counts column regardless of digit count", () => {
|
||||
// Regression guard: the name column is flex-1 (grows to fill whatever's
|
||||
// left on the line) — if the counts column were only ``shrink-0``
|
||||
// instead of a fixed width, a row with double/triple-digit counts would
|
||||
// shrink the name column by the difference, shifting the heatmap that
|
||||
// follows it to a different x than on rows with single-digit counts.
|
||||
const { container } = render(
|
||||
<CrossProviderDomainTitle
|
||||
name="Single-digit counts"
|
||||
stats={makeStats({ pass: 1, fail: 2, manual: 2 })}
|
||||
providers={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const countsContainer = screen.getByText("1").closest("div");
|
||||
expect(countsContainer).toHaveClass("w-[104px]");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the same fixed-width class for triple-digit counts", () => {
|
||||
render(
|
||||
<CrossProviderDomainTitle
|
||||
name="Triple-digit counts"
|
||||
stats={makeStats({ total: 153, pass: 100, fail: 40, manual: 13 })}
|
||||
providers={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const countsContainer = screen.getByText("100").closest("div");
|
||||
expect(countsContainer).toHaveClass("w-[104px]");
|
||||
});
|
||||
|
||||
it("renders one heatmap cell per provider with the rolled-up status in its accessible name", () => {
|
||||
render(
|
||||
<CrossProviderDomainTitle
|
||||
name="Provider heatmap"
|
||||
stats={makeStats({ byProvider: { aws: "PASS", azure: "FAIL" } })}
|
||||
providers={["aws", "azure", "gcp"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/AWS: PASS/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Azure: FAIL/)).toBeInTheDocument();
|
||||
// gcp has no entry in byProvider — falls back to "no scan".
|
||||
expect(screen.getByLabelText(/Google Cloud.*no scan/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the pass/fail/manual counts", () => {
|
||||
render(
|
||||
<CrossProviderDomainTitle
|
||||
name="Counts"
|
||||
stats={makeStats({ pass: 7, fail: 3, manual: 1 })}
|
||||
providers={["aws"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
|
||||
import type {
|
||||
DomainProviderStatus,
|
||||
DomainStats,
|
||||
} from "@/lib/compliance/cross-provider-insights";
|
||||
import {
|
||||
getProviderBadge,
|
||||
getProviderLabel,
|
||||
} from "@/lib/providers/provider-display";
|
||||
import { getProviderLabel } from "@/lib/providers/provider-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HEATMAP_CELL_BY_STATUS: Record<DomainProviderStatus, string> = {
|
||||
@@ -27,7 +25,10 @@ const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => {
|
||||
interface CrossProviderDomainTitleProps {
|
||||
name: string;
|
||||
stats: DomainStats;
|
||||
compatibleProviders: string[];
|
||||
/** Providers to render as heatmap columns — the detail view passes the
|
||||
* SCANNED providers (framework-compatible providers with no scan are
|
||||
* hidden here; they only appear, dimmed, on the overview). */
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,9 +38,9 @@ interface CrossProviderDomainTitleProps {
|
||||
*
|
||||
* Layout: [colored stripe] [domain name] [heatmap matrix] [stack bar]
|
||||
* [counts]. The stripe color reflects worst-case status (fail > pass >
|
||||
* manual > no-row); the heatmap matrix shows one cell per compatible
|
||||
* provider — dimmed for non-contributing ones — with the precise
|
||||
* rolled-up status surfaced via the native ``title`` attribute. We
|
||||
* manual > no-row); the heatmap matrix shows one cell per scanned
|
||||
* provider, with the precise rolled-up status surfaced via the native
|
||||
* ``title`` attribute. We
|
||||
* deliberately avoid Radix Tooltip here: 5 providers × 17 sections =
|
||||
* 85 cells per page would mount 85 ``TooltipProvider`` + ``Root``
|
||||
* pairs on initial render.
|
||||
@@ -47,7 +48,7 @@ interface CrossProviderDomainTitleProps {
|
||||
export const CrossProviderDomainTitle = ({
|
||||
name,
|
||||
stats,
|
||||
compatibleProviders,
|
||||
providers,
|
||||
}: CrossProviderDomainTitleProps) => {
|
||||
const total = Math.max(stats.total, 1);
|
||||
const passPct = (stats.pass / total) * 100;
|
||||
@@ -57,7 +58,10 @@ export const CrossProviderDomainTitle = ({
|
||||
return (
|
||||
<div
|
||||
data-domain-anchor={name}
|
||||
className="data-[flash=1]:bg-bg-fail/10 flex w-full items-stretch gap-3 transition-colors"
|
||||
// ``no-underline``: the parent AccordionTrigger applies
|
||||
// ``hover:underline`` to all its text, which otherwise paints through
|
||||
// the provider heatmap squares and stacked bar below on hover.
|
||||
className="data-[flash=1]:bg-bg-fail/10 flex w-full items-stretch gap-3 no-underline transition-colors"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -68,7 +72,7 @@ export const CrossProviderDomainTitle = ({
|
||||
/>
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-3 py-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-text-default truncate text-sm font-semibold">
|
||||
<span className="text-text-neutral-primary truncate text-sm font-semibold">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary mt-0.5 font-mono text-[10px]">
|
||||
@@ -77,10 +81,9 @@ export const CrossProviderDomainTitle = ({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{compatibleProviders.map((providerKey) => {
|
||||
{providers.map((providerKey) => {
|
||||
const status: DomainProviderStatus =
|
||||
stats.byProvider[providerKey] ?? "NO_ROW";
|
||||
const Badge = getProviderBadge(providerKey);
|
||||
const label = getProviderLabel(providerKey);
|
||||
const tooltip = `${label}: ${status === "NO_ROW" ? "no scan" : status}`;
|
||||
return (
|
||||
@@ -93,7 +96,11 @@ export const CrossProviderDomainTitle = ({
|
||||
HEATMAP_CELL_BY_STATUS[status],
|
||||
)}
|
||||
>
|
||||
{Badge ? <Badge size={12} className="opacity-90" /> : null}
|
||||
<ProviderBadgeIcon
|
||||
providerKey={providerKey}
|
||||
size={12}
|
||||
className="opacity-90"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -120,7 +127,15 @@ export const CrossProviderDomainTitle = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-text-neutral-secondary flex shrink-0 items-center gap-2 font-mono text-[11px] tabular-nums">
|
||||
{/* Fixed width, not just ``shrink-0``: the name column above is
|
||||
``flex-1`` (grows to fill whatever's left on the line), so a
|
||||
pass/fail/manual count that's wider on one row than another
|
||||
(e.g. double- vs triple-digit) shrinks or grows the name column
|
||||
by the difference — which shifts every ``shrink-0`` sibling that
|
||||
comes after it, including the heatmap. Locking this slot's width
|
||||
keeps the heatmap starting at the same x on every domain row
|
||||
regardless of how many digits its counts happen to have. */}
|
||||
<div className="text-text-neutral-secondary flex w-[104px] shrink-0 items-center justify-end gap-2 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-bg-pass">{stats.pass}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="text-bg-fail">{stats.fail}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
} from "@/types/compliance";
|
||||
|
||||
import { CrossProviderDomainTitle } from "./cross-provider-domain-title";
|
||||
import { GeneratePdfButton } from "./generate-pdf-button";
|
||||
|
||||
interface CrossProviderExplorerCardProps {
|
||||
attributes: CrossProviderComplianceOverviewAttributes;
|
||||
@@ -36,6 +38,18 @@ interface CrossProviderExplorerCardProps {
|
||||
* wants forced-open. Driven by the "Top Failing Domains" panel so a
|
||||
* click expands and scrolls to the target row. */
|
||||
forcedExpandedSectionKey?: string | null;
|
||||
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` values currently applied via
|
||||
* ``CrossProviderFilters`` — threaded through to ``GeneratePdfButton``
|
||||
* so the generated PDF respects the same narrowing as the on-screen
|
||||
* view. */
|
||||
providerTypeFilter?: string;
|
||||
providerIdFilter?: string;
|
||||
providerGroupsFilter?: string;
|
||||
/** A previously-generated PDF matching the current filters, resolved
|
||||
* server-side — ``null`` means "Generate PDF" should show, not
|
||||
* "Download PDF". */
|
||||
latestPdfReport: LatestCrossProviderPdfReport | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<{
|
||||
@@ -68,6 +82,10 @@ export const CrossProviderExplorerCard = ({
|
||||
attributes,
|
||||
insights,
|
||||
forcedExpandedSectionKey,
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderExplorerCardProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchKey, setSearchKey] = useState(0);
|
||||
@@ -204,6 +222,19 @@ export const CrossProviderExplorerCard = ({
|
||||
right border on intermediate viewports.
|
||||
*/}
|
||||
<div className="ml-auto flex max-w-full min-w-0 shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<GeneratePdfButton
|
||||
complianceId={attributes.compliance_id}
|
||||
scanIds={attributes.scan_ids}
|
||||
providerTypes={providerTypeFilter}
|
||||
providerIds={providerIdFilter}
|
||||
providerGroups={providerGroupsFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
frameworkLabel={
|
||||
attributes.name ||
|
||||
attributes.framework ||
|
||||
attributes.compliance_id
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -276,7 +307,7 @@ export const CrossProviderExplorerCard = ({
|
||||
<CrossProviderDomainTitle
|
||||
name={section.categoryName}
|
||||
stats={stats}
|
||||
compatibleProviders={insights.compatibleProviders}
|
||||
providers={insights.scannedProviders}
|
||||
/>
|
||||
) : (
|
||||
<span>{section.categoryName}</span>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { CrossProviderFilters } from "./cross-provider-filters";
|
||||
|
||||
const { providerAccountSelectorsProps, providerGroupSelectorProps } =
|
||||
vi.hoisted(() => ({
|
||||
providerAccountSelectorsProps: {
|
||||
value: undefined as { providers: ProviderProps[] } | undefined,
|
||||
},
|
||||
providerGroupSelectorProps: {
|
||||
value: undefined as { groups: ProviderGroup[] } | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
// This wrapper's own job is just "render the shared filter controls when
|
||||
// there's something to filter" — the controls' own instant/batch/URL
|
||||
// behaviour is already covered by provider-account-selectors.test.tsx and
|
||||
// provider-group-selector.test.tsx.
|
||||
vi.mock("@/components/filters/provider-account-selectors", () => ({
|
||||
ProviderAccountSelectors: (props: { providers: ProviderProps[] }) => {
|
||||
providerAccountSelectorsProps.value = props;
|
||||
return <div>Provider account selectors</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/provider-group-selector", () => ({
|
||||
ProviderGroupSelector: (props: { groups: ProviderGroup[] }) => {
|
||||
providerGroupSelectorProps.value = props;
|
||||
return <div>Provider group selector</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: ({ showCount }: { showCount?: boolean }) => (
|
||||
<button type="button">Clear all{showCount ? " (with count)" : ""}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeProvider = (id: string, provider: string): ProviderProps =>
|
||||
({
|
||||
id,
|
||||
type: "providers",
|
||||
attributes: { provider },
|
||||
}) as unknown as ProviderProps;
|
||||
|
||||
const makeGroup = (id: string, name: string): ProviderGroup =>
|
||||
({
|
||||
id,
|
||||
type: "provider-groups",
|
||||
attributes: { name },
|
||||
}) as unknown as ProviderGroup;
|
||||
|
||||
describe("CrossProviderFilters", () => {
|
||||
it("renders nothing when there are no compatible providers", () => {
|
||||
const { container } = render(
|
||||
<CrossProviderFilters providers={[]} providerGroups={[]} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the provider account selectors, group selector and clear-filters button when providers exist", () => {
|
||||
const providers = [makeProvider("azure-1", "azure")];
|
||||
|
||||
render(<CrossProviderFilters providers={providers} providerGroups={[]} />);
|
||||
|
||||
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
|
||||
expect(screen.getByText("Provider group selector")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /clear all/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the compatible providers straight through to the account selectors", () => {
|
||||
const providers = [
|
||||
makeProvider("azure-1", "azure"),
|
||||
makeProvider("gcp-1", "gcp"),
|
||||
];
|
||||
|
||||
render(<CrossProviderFilters providers={providers} providerGroups={[]} />);
|
||||
|
||||
expect(providerAccountSelectorsProps.value?.providers).toEqual(providers);
|
||||
});
|
||||
|
||||
it("passes every tenant provider group straight through to the group selector", () => {
|
||||
// Unlike ``providers``, groups are NOT narrowed to the framework's
|
||||
// compatible provider types — a group can span multiple types, same as
|
||||
// every other provider-group filter in the app.
|
||||
const providers = [makeProvider("azure-1", "azure")];
|
||||
const providerGroups = [makeGroup("group-1", "Production")];
|
||||
|
||||
render(
|
||||
<CrossProviderFilters
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(providerGroupSelectorProps.value?.groups).toEqual(providerGroups);
|
||||
});
|
||||
|
||||
it("shows the active-filter count on the clear button", () => {
|
||||
// Regression guard: dropping ``showCount`` would silently hide how many
|
||||
// filters are active, matching how every other filter bar in the app
|
||||
// surfaces it (findings, resources, scans).
|
||||
render(
|
||||
<CrossProviderFilters
|
||||
providers={[makeProvider("azure-1", "azure")]}
|
||||
providerGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/with count/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
interface CrossProviderFiltersProps {
|
||||
/** Every provider account of a type compatible with the current
|
||||
* framework — already narrowed by the caller to ``compatible_providers``.
|
||||
* Not filtered by connection status: disconnected accounts still show up
|
||||
* so the user can see (and filter to) them, same as the account
|
||||
* selectors elsewhere in the app. */
|
||||
providers: ProviderProps[];
|
||||
/** Every provider group in the tenant — not narrowed by framework
|
||||
* compatibility, same as every other provider-group filter in the app
|
||||
* (a group can span multiple provider types). */
|
||||
providerGroups: ProviderGroup[];
|
||||
}
|
||||
|
||||
const FILTER_CONTROL_CLASS =
|
||||
"w-full sm:max-w-[240px] sm:min-w-[180px] sm:flex-1";
|
||||
|
||||
/**
|
||||
* Provider type / account / group narrowing for the cross-provider
|
||||
* compliance detail page. Instant mode: picking a value pushes
|
||||
* ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` straight into the URL, which the server
|
||||
* component re-reads on navigation and re-runs the aggregation against just
|
||||
* the selected providers (unlike the search/status controls in
|
||||
* ``CrossProviderExplorerCard``, which only filter the already-fetched
|
||||
* payload client-side).
|
||||
*/
|
||||
export const CrossProviderFilters = ({
|
||||
providers,
|
||||
providerGroups,
|
||||
}: CrossProviderFiltersProps) => {
|
||||
if (providers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
providerSelectorClassName={FILTER_CONTROL_CLASS}
|
||||
accountSelectorClassName={FILTER_CONTROL_CLASS}
|
||||
/>
|
||||
<div className={FILTER_CONTROL_CLASS}>
|
||||
<ProviderGroupSelector groups={providerGroups} />
|
||||
</div>
|
||||
<ClearFiltersButton showCount />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("CrossProviderHeader", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "cross-provider-header.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("accepts a complianceId prop to build the Prowler Hub link", () => {
|
||||
expect(source).toContain("complianceId");
|
||||
expect(source).toContain("getProwlerHubComplianceUrl(complianceId)");
|
||||
});
|
||||
|
||||
it("opens the Prowler Hub link in a new, safely-referrer-stripped tab", () => {
|
||||
// ``noopener`` prevents the opened tab from reaching back into this
|
||||
// window via ``window.opener``; ``noreferrer`` also strips the
|
||||
// referrer header. Both matter for any ``target="_blank"`` link to an
|
||||
// external origin.
|
||||
expect(source).toMatch(
|
||||
/target="_blank"[\s\S]{0,40}rel="noopener noreferrer"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
|
||||
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
|
||||
|
||||
import { ProviderCoveragePanel } from "./provider-coverage-panel";
|
||||
import { ScoreDonut } from "./score-donut";
|
||||
@@ -19,6 +23,9 @@ interface CrossProviderHeaderProps {
|
||||
version: string;
|
||||
/** Long description from the universal JSON. */
|
||||
description: string;
|
||||
/** Universal framework id (e.g. ``cis_controls_8.1``) — builds the
|
||||
* Prowler Hub reference link. */
|
||||
complianceId: string;
|
||||
insights: CrossProviderInsights;
|
||||
/** Click handler when a top-failing-domain entry is selected. */
|
||||
onDomainSelect?: (domainName: string) => void;
|
||||
@@ -45,6 +52,7 @@ export const CrossProviderHeader = ({
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
complianceId,
|
||||
insights,
|
||||
onDomainSelect,
|
||||
}: CrossProviderHeaderProps) => {
|
||||
@@ -76,6 +84,22 @@ export const CrossProviderHeader = ({
|
||||
<span className="border-border-neutral-secondary text-text-neutral-secondary rounded border px-1.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
|
||||
Universal
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-xs"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={getProwlerHubComplianceUrl(complianceId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
prefetch={false}
|
||||
>
|
||||
View on Prowler Hub
|
||||
<SquareArrowOutUpRight className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-text-neutral-secondary mt-1 line-clamp-2 text-xs">
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
|
||||
|
||||
import { CrossProviderPdfWatcher } from "./cross-provider-pdf-watcher";
|
||||
|
||||
const { getTaskMock, toastMock } = vi.hoisted(() => ({
|
||||
getTaskMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/task", () => ({
|
||||
getTask: getTaskMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/toast", () => ({
|
||||
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
toast: toastMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
// Reset the (module-level) store between tests so tracked generations from
|
||||
// one test never leak into the next.
|
||||
const resetStore = () => useCrossProviderPdfStore.setState({ generations: {} });
|
||||
|
||||
describe("CrossProviderPdfWatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getTaskMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it("fires the ready toast for a tracked generation even with no button mounted", async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: { attributes: { state: "completed" } },
|
||||
});
|
||||
|
||||
// The button is NOT rendered here — only the app-wide watcher. This is
|
||||
// exactly the "user navigated away from the generating page" case: the
|
||||
// notification must still arrive.
|
||||
render(<CrossProviderPdfWatcher />);
|
||||
|
||||
useCrossProviderPdfStore.getState().trackGeneration({
|
||||
taskId: "task-123",
|
||||
signature: "csa_ccm_4.0|scan-1|||",
|
||||
reportUrl: "/compliance/csa_ccm_4.0?filter=x",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
|
||||
expect(getTaskMock).toHaveBeenCalledWith("task-123");
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
const payload = toastMock.mock.calls[0]?.[0];
|
||||
expect(payload.title).toBe("PDF report ready");
|
||||
// The action links back to the page the report was generated from.
|
||||
expect(payload.action.props.children.props.href).toBe(
|
||||
"/compliance/csa_ccm_4.0?filter=x",
|
||||
);
|
||||
// Completion is recorded so a still-mounted button can flip to Download.
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-123"].status,
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps polling while the task is still running and does not toast", async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: { attributes: { state: "executing" } },
|
||||
});
|
||||
render(<CrossProviderPdfWatcher />);
|
||||
|
||||
useCrossProviderPdfStore.getState().trackGeneration({
|
||||
taskId: "task-run",
|
||||
signature: "sig",
|
||||
reportUrl: "/x",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
|
||||
expect(getTaskMock).toHaveBeenCalled();
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-run"].status,
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("tolerates transient getTask errors and gives up only after several", async () => {
|
||||
getTaskMock.mockResolvedValue({ error: "boom" });
|
||||
render(<CrossProviderPdfWatcher />);
|
||||
|
||||
useCrossProviderPdfStore.getState().trackGeneration({
|
||||
taskId: "task-err",
|
||||
signature: "sig",
|
||||
reportUrl: "/x",
|
||||
});
|
||||
|
||||
// Two consecutive errors: still below the threshold — no failure toast.
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-err"].status,
|
||||
).toBe("running");
|
||||
|
||||
// Third consecutive error crosses the threshold — fail and stop.
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock.mock.calls[0]?.[0].variant).toBe("destructive");
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-err"].status,
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("surfaces a terminal failure state with the task's error message", async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: { state: "failed", result: { error: "no scans" } },
|
||||
},
|
||||
});
|
||||
render(<CrossProviderPdfWatcher />);
|
||||
|
||||
useCrossProviderPdfStore.getState().trackGeneration({
|
||||
taskId: "task-fail",
|
||||
signature: "sig",
|
||||
reportUrl: "/x",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
const payload = toastMock.mock.calls[0]?.[0];
|
||||
expect(payload.variant).toBe("destructive");
|
||||
expect(payload.description).toContain("no scans");
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-fail"].status,
|
||||
).toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { getTask } from "@/actions/task";
|
||||
import { toast, ToastAction } from "@/components/ui/toast";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
|
||||
import type { GetTaskResponse, TaskState } from "@/types/tasks";
|
||||
|
||||
type PdfTaskResult = { error?: string };
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
// Allowlist of "still running" states rather than hardcoding "done" states:
|
||||
// treating anything NOT in this set as terminal means an unhandled state
|
||||
// (e.g. "cancelled", or a future state we don't know about yet) stops
|
||||
// polling and surfaces an error instead of spinning forever.
|
||||
const RUNNING_STATES = new Set<TaskState>([
|
||||
"available",
|
||||
"scheduled",
|
||||
"executing",
|
||||
]);
|
||||
|
||||
// Transient getTask failures (network blip, one 5xx) must not abort a
|
||||
// generation that is still running server-side — give up only after several
|
||||
// consecutive failures.
|
||||
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
||||
|
||||
/**
|
||||
* Single, app-wide poller for cross-provider PDF generations.
|
||||
*
|
||||
* Mounted once in the ``(prowler)`` layout so it survives navigation between
|
||||
* views. When the user clicks "Generate PDF" the button registers the task in
|
||||
* ``useCrossProviderPdfStore`` and unmounts freely; this watcher keeps polling
|
||||
* every tracked generation and fires the "ready"/"failed" toast when each one
|
||||
* settles — so the notification arrives even if the user has since switched to
|
||||
* the overview or another framework. The toast's action links back to the
|
||||
* exact page the report was generated from (where the button now offers
|
||||
* "Download PDF").
|
||||
*/
|
||||
export const CrossProviderPdfWatcher = () => {
|
||||
const generations = useCrossProviderPdfStore((state) => state.generations);
|
||||
const markCompleted = useCrossProviderPdfStore(
|
||||
(state) => state.markCompleted,
|
||||
);
|
||||
const markFailed = useCrossProviderPdfStore((state) => state.markFailed);
|
||||
|
||||
// Latest generations snapshot for the interval callback to read without
|
||||
// being re-created (and re-scheduling the interval) on every store change.
|
||||
const generationsRef = useRef(generations);
|
||||
generationsRef.current = generations;
|
||||
|
||||
// Per-task bookkeeping (in-flight tick + consecutive error count) that must
|
||||
// NOT trigger React re-renders — a plain ref keyed by task id.
|
||||
const pollStateRef = useRef<Map<string, { busy: boolean; errors: number }>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// A single mount-time poller (setup on mount, cleanup on unmount). It reads
|
||||
// the live generations snapshot from ``generationsRef`` and the store
|
||||
// actions (``markCompleted``/``markFailed``) are stable Zustand references,
|
||||
// so capturing them at mount is safe — no dependency array needed.
|
||||
useMountEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const running = Object.values(generationsRef.current).filter(
|
||||
(generation) => generation.status === "running",
|
||||
);
|
||||
|
||||
// Prune bookkeeping for tasks that are no longer running so the map
|
||||
// doesn't grow unbounded across a long session.
|
||||
const runningIds = new Set(
|
||||
running.map((generation) => generation.taskId),
|
||||
);
|
||||
for (const trackedId of Array.from(pollStateRef.current.keys())) {
|
||||
if (!runningIds.has(trackedId)) pollStateRef.current.delete(trackedId);
|
||||
}
|
||||
|
||||
for (const generation of running) {
|
||||
let pollState = pollStateRef.current.get(generation.taskId);
|
||||
if (!pollState) {
|
||||
pollState = { busy: false, errors: 0 };
|
||||
pollStateRef.current.set(generation.taskId, pollState);
|
||||
}
|
||||
// Skip a task whose previous poll (a whole-task fetch) is still in
|
||||
// flight — otherwise a slow tick could double-fire the ready toast.
|
||||
if (pollState.busy) continue;
|
||||
pollState.busy = true;
|
||||
|
||||
const state = pollState;
|
||||
void (async () => {
|
||||
try {
|
||||
const task = (await getTask(
|
||||
generation.taskId,
|
||||
)) as GetTaskResponse<PdfTaskResult>;
|
||||
|
||||
if ("error" in task) {
|
||||
state.errors += 1;
|
||||
if (state.errors < MAX_CONSECUTIVE_POLL_ERRORS) return;
|
||||
markFailed(generation.taskId);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "PDF generation failed",
|
||||
description: task.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.errors = 0;
|
||||
|
||||
const taskState = task.data?.attributes?.state;
|
||||
if (taskState && RUNNING_STATES.has(taskState)) return;
|
||||
|
||||
if (taskState === "completed") {
|
||||
markCompleted(generation.taskId);
|
||||
toast({
|
||||
title: "PDF report ready",
|
||||
description:
|
||||
"Your combined compliance PDF has been generated and is ready to download.",
|
||||
action: generation.reportUrl ? (
|
||||
<ToastAction altText="Go to the report" asChild>
|
||||
<Link href={generation.reportUrl}>View report</Link>
|
||||
</ToastAction>
|
||||
) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other terminal state ("failed", "cancelled", or an
|
||||
// unrecognized one) — stop tracking rather than spin forever.
|
||||
markFailed(generation.taskId);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "PDF generation failed",
|
||||
description:
|
||||
task.data?.attributes?.result?.error ||
|
||||
`The report generation task did not complete (state: ${taskState ?? "unknown"}).`,
|
||||
});
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon, FileDown, Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import {
|
||||
generateCrossProviderCompliancePdf,
|
||||
getCrossProviderCompliancePdf,
|
||||
} from "@/actions/compliances";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { downloadFile } from "@/lib/helper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
|
||||
|
||||
interface GeneratePdfButtonProps {
|
||||
/** e.g. "csa_ccm_4.0" — the universal framework currently on screen. */
|
||||
complianceId: string;
|
||||
/**
|
||||
* The exact scan ids backing the overview currently rendered
|
||||
* (``attributes.scan_ids``), so the PDF matches what the user sees
|
||||
* instead of re-resolving "latest scan per provider" a second time.
|
||||
*/
|
||||
scanIds: string[];
|
||||
/**
|
||||
* The same ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
|
||||
* ``filter[provider_groups__in]`` values currently applied via
|
||||
* ``CrossProviderFilters`` (raw comma-separated strings straight from the
|
||||
* URL search params). ``scanIds`` is already narrowed by these — passing
|
||||
* them too is defense-in-depth for the edge case where ``scanIds`` ends up
|
||||
* empty and generation would otherwise fall back to auto-selecting across
|
||||
* every compatible provider instead of respecting an active filter.
|
||||
*/
|
||||
providerTypes?: string;
|
||||
providerIds?: string;
|
||||
providerGroups?: string;
|
||||
/**
|
||||
* A report already generated for these exact filters, resolved
|
||||
* server-side alongside the page's data fetch — ``null`` means none
|
||||
* exists yet (or it went stale because a contributing provider completed
|
||||
* a new scan since), so the button shows "Generate" instead of
|
||||
* "Download". Whenever the caller's filters change, a fresh value flows
|
||||
* in here via a normal prop update (App Router re-runs the server
|
||||
* component on navigation) and this component resyncs to it — state is
|
||||
* derived per filter signature, so a report invalidated under the previous
|
||||
* filters simply stops matching once the signature changes.
|
||||
*/
|
||||
latestPdfReport: LatestCrossProviderPdfReport | null;
|
||||
/** Human-readable framework label (e.g. "CSA Cloud Controls Matrix v4.0")
|
||||
* used to pre-fill the "name your report" dialog. */
|
||||
frameworkLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Default report name pre-filled in the dialog: framework + today's date,
|
||||
* so it's descriptive and unique out of the box while still editable. */
|
||||
const buildDefaultReportName = (frameworkLabel: string) => {
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(today.getDate()).padStart(2, "0");
|
||||
return `${frameworkLabel} - ${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const formatGeneratedAt = (iso?: string) => {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* "Generate PDF" / "Download PDF" button for the cross-provider compliance
|
||||
* view. Three states:
|
||||
*
|
||||
* - No report for the current filters ("idle"): "Generate PDF". Triggers
|
||||
* ad-hoc generation, however long it takes (the backend job has no fixed
|
||||
* time budget). The button does NOT poll itself: it registers the task in
|
||||
* ``useCrossProviderPdfStore`` and the app-wide ``CrossProviderPdfWatcher``
|
||||
* (mounted once in the layout) polls it to completion. That's what lets
|
||||
* the "ready" toast still fire after the user navigates away from this
|
||||
* page — the button unmounts, but the watcher and store persist.
|
||||
* - Mid-generation: "Generating..." (disabled, spinner) — derived from a
|
||||
* ``running`` entry in the store whose signature matches this button's.
|
||||
* - A report exists for the current filters ("available", because the
|
||||
* server resolved one on load or a store generation for this signature
|
||||
* just completed): "Download PDF" — downloads immediately, no regen.
|
||||
*
|
||||
* Generation does NOT auto-download. When the watcher sees the job finish it
|
||||
* fires a "ready" toast whose action links back to the page the report was
|
||||
* generated from (where the button is now "Download PDF"); the user chooses
|
||||
* when to download.
|
||||
*
|
||||
* Changing provider/scan filters is a real navigation (URL search params),
|
||||
* so the parent re-fetches and this component receives a fresh
|
||||
* ``latestPdfReport`` prop; state is derived per filter signature, so a
|
||||
* report generated under the previous filters never lingers as "available"
|
||||
* after the filters change.
|
||||
*/
|
||||
export const GeneratePdfButton = ({
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
latestPdfReport,
|
||||
frameworkLabel,
|
||||
className,
|
||||
}: GeneratePdfButtonProps) => {
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
// "Name your report" dialog state.
|
||||
const [nameDialogOpen, setNameDialogOpen] = useState(false);
|
||||
const [reportName, setReportName] = useState("");
|
||||
// Server-resolved report that turned out not to be servable when the user
|
||||
// actually clicked Download (file expired/cleaned up). Without this, the
|
||||
// stale ``latestPdfReport`` prop would keep the button on "Download PDF"
|
||||
// forever, every click failing with a toast telling the user to press a
|
||||
// Generate button that never appears. Tagged with the filter signature it
|
||||
// was invalidated under, so it's derived away (not reset via an effect)
|
||||
// the moment the filters change and a fresh ``latestPdfReport`` flows in.
|
||||
const [invalidated, setInvalidated] = useState<{
|
||||
signature: string;
|
||||
taskId: string;
|
||||
} | null>(null);
|
||||
|
||||
const generations = useCrossProviderPdfStore((state) => state.generations);
|
||||
const trackGeneration = useCrossProviderPdfStore(
|
||||
(state) => state.trackGeneration,
|
||||
);
|
||||
const removeGeneration = useCrossProviderPdfStore(
|
||||
(state) => state.removeGeneration,
|
||||
);
|
||||
|
||||
// The filter signature this button was rendered for. When it changes
|
||||
// (the user picked different providers, or navigated to a different
|
||||
// framework), any report generated under the *previous* signature is no
|
||||
// longer relevant — state is derived per signature, so it simply stops
|
||||
// matching and the button falls back to whatever the server resolved for
|
||||
// the new signature (``latestPdfReport``, already correct because it was
|
||||
// fetched for these new filters).
|
||||
const filterSignature = [
|
||||
complianceId,
|
||||
scanIds.join(","),
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
].join("|");
|
||||
|
||||
// The invalidation only applies to the signature it happened under; once
|
||||
// the filters change it stops matching and the button falls back to the
|
||||
// freshly resolved ``latestPdfReport`` — no reset effect needed.
|
||||
const invalidatedTaskId =
|
||||
invalidated?.signature === filterSignature ? invalidated.taskId : null;
|
||||
|
||||
// Derive this button's view straight from the shared store, matched on the
|
||||
// signature the generation was started under: a generation started here is
|
||||
// only "mine" (and its finished report only offered for download) while the
|
||||
// rendered filters still match the ones it was generated for. A ``running``
|
||||
// entry takes priority over a ``completed`` one for the same signature —
|
||||
// regenerating after invalidating a stale report leaves both, and the button
|
||||
// must show "Generating..." (not the old report's "Download").
|
||||
const tracked = Object.values(generations);
|
||||
const runningForSignature = tracked.find(
|
||||
(generation) =>
|
||||
generation.signature === filterSignature &&
|
||||
generation.status === "running",
|
||||
);
|
||||
const completedForSignature = tracked.find(
|
||||
(generation) =>
|
||||
generation.signature === filterSignature &&
|
||||
generation.status === "completed" &&
|
||||
generation.taskId !== invalidatedTaskId,
|
||||
);
|
||||
const isGenerating = isStarting || Boolean(runningForSignature);
|
||||
const storeReport = completedForSignature
|
||||
? { taskId: completedForSignature.taskId }
|
||||
: null;
|
||||
const serverReport =
|
||||
latestPdfReport && latestPdfReport.taskId !== invalidatedTaskId
|
||||
? latestPdfReport
|
||||
: null;
|
||||
const availableReport: Pick<LatestCrossProviderPdfReport, "taskId"> &
|
||||
Partial<LatestCrossProviderPdfReport> = storeReport ??
|
||||
serverReport ?? { taskId: "" };
|
||||
const hasAvailableReport = Boolean(storeReport ?? serverReport);
|
||||
|
||||
// Read the live filter signature from a ref inside the async generate
|
||||
// handler (refs are exempt from the hook dependency array and always hold
|
||||
// the current-render value) so the store entry is tagged with the filters
|
||||
// in effect at click time.
|
||||
const currentSignatureRef = useRef(filterSignature);
|
||||
currentSignatureRef.current = filterSignature;
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (chosenName: string) => {
|
||||
if (isGenerating) return;
|
||||
setIsStarting(true);
|
||||
const signature = currentSignatureRef.current;
|
||||
// Snapshot the page URL (path + filters) now, so the watcher's "ready"
|
||||
// toast can link back here even if the user has navigated elsewhere by
|
||||
// the time generation finishes.
|
||||
const reportUrl = window.location.pathname + window.location.search;
|
||||
toast({
|
||||
title: "Generating PDF report",
|
||||
description:
|
||||
"This may take a while — the report combines every provider's latest scan for the current filters. You'll get a notification when it's ready to download; you can keep browsing in the meantime.",
|
||||
});
|
||||
|
||||
const trimmedName = chosenName.trim();
|
||||
const result = await generateCrossProviderCompliancePdf({
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
// Empty → server falls back to a unique timestamped default.
|
||||
reportName: trimmedName.length > 0 ? trimmedName : undefined,
|
||||
});
|
||||
setIsStarting(false);
|
||||
|
||||
if ("error" in result) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to start PDF generation",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand off to the app-wide watcher: it polls to completion and fires the
|
||||
// "ready" toast even if this button unmounts on navigation.
|
||||
trackGeneration({ taskId: result.taskId, signature, reportUrl });
|
||||
},
|
||||
[
|
||||
complianceId,
|
||||
scanIds,
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
isGenerating,
|
||||
trackGeneration,
|
||||
],
|
||||
);
|
||||
|
||||
// Opening the dialog seeds the input with a descriptive default the user
|
||||
// can accept or overwrite.
|
||||
const openNameDialog = useCallback(() => {
|
||||
setReportName(buildDefaultReportName(frameworkLabel));
|
||||
setNameDialogOpen(true);
|
||||
}, [frameworkLabel]);
|
||||
|
||||
const confirmGenerate = useCallback(() => {
|
||||
setNameDialogOpen(false);
|
||||
void handleGenerate(reportName);
|
||||
}, [handleGenerate, reportName]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!hasAvailableReport || isDownloading || isGenerating) return;
|
||||
setIsDownloading(true);
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: "Preparing the combined compliance PDF report.",
|
||||
});
|
||||
try {
|
||||
const result = await getCrossProviderCompliancePdf(
|
||||
availableReport.taskId,
|
||||
);
|
||||
if ("pending" in result && result.pending) {
|
||||
// The cached report reference is no longer servable (e.g. the
|
||||
// underlying file expired/was cleaned up). Invalidate BOTH sources
|
||||
// feeding ``availableReport`` — dropping only the store entry would
|
||||
// leave the server-resolved ``latestPdfReport`` prop keeping the
|
||||
// button on "Download PDF" forever, telling the user to click a
|
||||
// Generate button that never appears.
|
||||
removeGeneration(availableReport.taskId);
|
||||
setInvalidated({
|
||||
signature: currentSignatureRef.current,
|
||||
taskId: availableReport.taskId,
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Report No Longer Available",
|
||||
description:
|
||||
"This report isn't ready to download anymore. Click Generate PDF to create a new one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
"The combined compliance PDF report has been downloaded successfully.",
|
||||
toast,
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [
|
||||
availableReport.taskId,
|
||||
hasAvailableReport,
|
||||
isDownloading,
|
||||
isGenerating,
|
||||
removeGeneration,
|
||||
]);
|
||||
|
||||
const buttonClassName = cn(
|
||||
"border-button-primary text-button-primary hover:bg-button-primary/10 h-8 px-2 text-xs",
|
||||
className,
|
||||
);
|
||||
|
||||
const nameDialog = (
|
||||
<Dialog open={nameDialogOpen} onOpenChange={setNameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Name your report</DialogTitle>
|
||||
<DialogDescription>
|
||||
This becomes the downloaded PDF's filename. Leave it as is or
|
||||
give it a name you'll recognize later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={reportName}
|
||||
onChange={(e) => setReportName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
confirmGenerate();
|
||||
}
|
||||
}}
|
||||
placeholder="Report name"
|
||||
aria-label="Report name"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setNameDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmGenerate}>
|
||||
<FileDown className="size-3" />
|
||||
Generate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
let button: ReactNode;
|
||||
if (isGenerating) {
|
||||
button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
aria-label="Generating combined PDF report"
|
||||
className={buttonClassName}
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Generating...
|
||||
</Button>
|
||||
);
|
||||
} else if (hasAvailableReport) {
|
||||
const generatedAtLabel = formatGeneratedAt(availableReport.generatedAt);
|
||||
const downloadButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download combined PDF report"
|
||||
className={buttonClassName}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="size-3" />
|
||||
)}
|
||||
{isDownloading ? "Downloading..." : "Download PDF"}
|
||||
</Button>
|
||||
);
|
||||
button = generatedAtLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{downloadButton}</TooltipTrigger>
|
||||
<TooltipContent>Generated on {generatedAtLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
downloadButton
|
||||
);
|
||||
} else {
|
||||
button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openNameDialog}
|
||||
aria-label="Generate combined PDF report"
|
||||
className={buttonClassName}
|
||||
>
|
||||
<FileDown className="size-3" />
|
||||
Generate PDF
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{nameDialog}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Slash } from "lucide-react";
|
||||
|
||||
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
|
||||
import type { ProviderCoverage } from "@/lib/compliance/cross-provider-insights";
|
||||
import {
|
||||
getProviderBadge,
|
||||
getProviderLabel,
|
||||
} from "@/lib/providers/provider-display";
|
||||
import { getProviderLabel } from "@/lib/providers/provider-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderCoveragePanelProps {
|
||||
@@ -14,11 +10,17 @@ interface ProviderCoveragePanelProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-of-score panel listing every compatible provider with its
|
||||
* per-provider score and a clear "scan available" / "no scan yet"
|
||||
* marker. Keeps non-contributing providers visible (dimmed) instead of
|
||||
* hiding them — the user needs to know coverage gaps as much as
|
||||
* coverage itself.
|
||||
* Right-of-score panel listing every SCANNED provider with its
|
||||
* per-provider score. In the detail view we only show providers we
|
||||
* actually have scans for — framework-compatible providers with no scan
|
||||
* belong on the overview (where they're listed dimmed as "no scan yet"),
|
||||
* not here.
|
||||
*
|
||||
* Laid out as a tile grid rather than a single-column list: universal
|
||||
* frameworks can declare 15+ compatible providers, and one row per
|
||||
* provider made this panel several screens taller than its siblings
|
||||
* (score donut, top failing domains). The account-count detail that no
|
||||
* longer fits inline moves to the tile's ``title`` tooltip.
|
||||
*/
|
||||
export const ProviderCoveragePanel = ({
|
||||
coverage,
|
||||
@@ -28,70 +30,70 @@ export const ProviderCoveragePanel = ({
|
||||
<h3 className="text-text-neutral-secondary text-[11px] font-semibold tracking-wider uppercase">
|
||||
Provider Coverage
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{coverage.length === 0 && (
|
||||
<p className="text-text-neutral-secondary text-xs italic">
|
||||
No scanned providers for this framework yet.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{coverage.map((entry) => {
|
||||
const Badge = getProviderBadge(entry.key);
|
||||
const label = getProviderLabel(entry.key);
|
||||
const Icon = entry.contributing ? Check : Slash;
|
||||
const dimmed = !entry.contributing;
|
||||
const tooltip = entry.contributing
|
||||
? `${label}: ${entry.scorePercent}% — ${entry.pass} pass / ${entry.fail} fail (${entry.total} total)` +
|
||||
(entry.accountCount > 1
|
||||
? ` · ${entry.accountCount} accounts`
|
||||
: "")
|
||||
: `${label}: scanned, no data yet`;
|
||||
return (
|
||||
<li
|
||||
key={entry.key}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary flex items-center gap-3 rounded-md border px-3 py-2",
|
||||
"border-border-neutral-secondary flex flex-col gap-1.5 rounded-md border px-2.5 py-2",
|
||||
dimmed && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{Badge ? <Badge size={20} /> : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-text-default flex min-w-0 items-baseline gap-1.5 truncate text-xs font-semibold">
|
||||
{label}
|
||||
{entry.accountCount > 1 && (
|
||||
<span className="text-text-neutral-secondary text-[10px] font-medium tracking-wider uppercase">
|
||||
{entry.accountCount} accounts
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProviderBadgeIcon providerKey={entry.key} size={16} />
|
||||
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-xs font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
{entry.contributing ? (
|
||||
<span className="shrink-0 font-mono text-[11px] font-bold tabular-nums">
|
||||
{entry.scorePercent}%
|
||||
</span>
|
||||
{entry.contributing ? (
|
||||
<span className="font-mono text-xs font-bold tabular-nums">
|
||||
{entry.scorePercent}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary text-[10px] tracking-wider uppercase">
|
||||
No scan
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-default-200 dark:bg-default-100/30 h-1.5 w-full overflow-hidden rounded-full">
|
||||
{entry.contributing && entry.total > 0 ? (
|
||||
<div
|
||||
className="bg-bg-pass h-full"
|
||||
style={{ width: `${entry.scorePercent}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{entry.contributing && (
|
||||
<span className="text-text-neutral-secondary font-mono text-[10px]">
|
||||
{entry.pass} / {entry.total} pass
|
||||
{entry.fail > 0 && (
|
||||
<span className="text-bg-fail ml-2">
|
||||
{entry.fail} fail
|
||||
</span>
|
||||
)}
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary shrink-0 text-[9px] font-medium tracking-wider uppercase">
|
||||
No data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
entry.contributing
|
||||
? "text-bg-pass"
|
||||
: "text-text-neutral-secondary",
|
||||
<div className="bg-default-200 dark:bg-default-100/30 h-1 w-full overflow-hidden rounded-full">
|
||||
{entry.contributing && entry.total > 0 ? (
|
||||
<div
|
||||
className="bg-bg-pass h-full"
|
||||
style={{ width: `${entry.scorePercent}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Always reserve this line — even for "no scan" tiles —
|
||||
so every tile in the grid is the same height regardless
|
||||
of which row it lands in. */}
|
||||
<span className="text-text-neutral-secondary font-mono text-[9px]">
|
||||
{entry.contributing ? (
|
||||
<>
|
||||
{entry.pass}/{entry.total} pass
|
||||
{entry.fail > 0 && (
|
||||
<span className="text-bg-fail ml-1.5">
|
||||
{entry.fail} fail
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<> </>
|
||||
)}
|
||||
strokeWidth={entry.contributing ? 3 : 2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const TopFailingDomainsPanel = ({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-text-default truncate text-xs font-semibold">
|
||||
<span className="text-text-neutral-primary truncate text-xs font-semibold">
|
||||
{domain.name}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary font-mono text-[10px]">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderBadgeIcon } from "./provider-badge-icon";
|
||||
|
||||
describe("ProviderBadgeIcon", () => {
|
||||
it("renders the registered badge component for a known provider", () => {
|
||||
const { container } = render(
|
||||
<ProviderBadgeIcon providerKey="aws" size={16} />,
|
||||
);
|
||||
|
||||
// The real AWS badge is an <svg>; the initials fallback never mounts.
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(screen.queryByText("AW")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to an initials chip for a provider with a label but no icon", () => {
|
||||
// ``linode`` is declared by the SDK's cross-provider CIS Controls
|
||||
// template but has no dedicated badge component yet — the exact gap
|
||||
// ``ProviderBadgeIcon`` exists to paper over.
|
||||
render(<ProviderBadgeIcon providerKey="linode" size={16} />);
|
||||
|
||||
expect(screen.getByText("LI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the fallback chip from assistive tech", () => {
|
||||
render(<ProviderBadgeIcon providerKey="linode" size={16} />);
|
||||
|
||||
expect(screen.getByText("LI")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("derives fallback initials from the uppercased key when the provider is entirely unknown", () => {
|
||||
// Regression guard: an unrecognized key must not throw, and should
|
||||
// still render *something* identifiable rather than a blank square.
|
||||
render(<ProviderBadgeIcon providerKey="zzznope" size={16} />);
|
||||
|
||||
expect(screen.getByText("ZZ")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sizes the fallback chip from the size prop", () => {
|
||||
render(<ProviderBadgeIcon providerKey="linode" size={20} />);
|
||||
|
||||
expect(screen.getByText("LI")).toHaveStyle({
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
getProviderBadge,
|
||||
getProviderLabel,
|
||||
} from "@/lib/providers/provider-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderBadgeIconProps {
|
||||
providerKey: string;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a provider key to its dedicated badge icon. Providers without
|
||||
* one yet (e.g. newly onboarded backend-only providers that haven't had
|
||||
* artwork added) fall back to an initials chip instead of rendering
|
||||
* nothing, so new providers never show up as a blank square.
|
||||
*/
|
||||
export const ProviderBadgeIcon = ({
|
||||
providerKey,
|
||||
size,
|
||||
className,
|
||||
}: ProviderBadgeIconProps) => {
|
||||
const Badge = getProviderBadge(providerKey);
|
||||
if (Badge) {
|
||||
return <Badge size={size} className={className} />;
|
||||
}
|
||||
|
||||
const label = getProviderLabel(providerKey);
|
||||
const initials = label.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-default-300 text-default-700 dark:bg-default-100/40 dark:text-default-500 flex shrink-0 items-center justify-center rounded-[22%] font-mono leading-none font-bold",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: Math.max(size * 0.4, 7) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isValidElement } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// cis-controls.tsx's accordion builders transitively import the client
|
||||
// accordion chain, which pulls in server-only code (next-auth) when loaded
|
||||
// under vitest. Stub them the same way compliance-mapper.test.ts does —
|
||||
// we're testing the mapper's data wiring, not these components' rendering.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({ ClientAccordionContent: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
|
||||
() => ({ ComplianceAccordionRequirementTitle: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({ ComplianceAccordionTitle: () => null }),
|
||||
);
|
||||
|
||||
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
|
||||
|
||||
import { mapComplianceData, toAccordionItems } from "./cis-controls";
|
||||
import { crossProviderToMapperInput } from "./cross-provider-adapter";
|
||||
|
||||
const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = {
|
||||
compliance_id: "cis_controls_8.1",
|
||||
framework: "CIS-Controls",
|
||||
name: "CIS-Controls",
|
||||
version: "8.1",
|
||||
description: "CIS Critical Security Controls",
|
||||
compatible_providers: ["azure", "gcp"],
|
||||
requested_providers: ["azure", "gcp"],
|
||||
providers: ["azure", "gcp"],
|
||||
scan_ids: ["scan-azure-uuid", "scan-gcp-uuid"],
|
||||
scan_ids_by_provider: {
|
||||
azure: ["scan-azure-uuid"],
|
||||
gcp: ["scan-gcp-uuid"],
|
||||
},
|
||||
requirements_passed: 1,
|
||||
requirements_failed: 0,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 1,
|
||||
requirements: [
|
||||
{
|
||||
id: "1.1",
|
||||
name: "Establish and Maintain Detailed Enterprise Asset Inventory",
|
||||
description: "Establish and maintain an inventory.",
|
||||
attributes: {
|
||||
Section: "1. Inventory and Control of Enterprise Assets",
|
||||
Function: "Identify",
|
||||
AssetType: "Devices",
|
||||
ImplementationGroups: ["IG1", "IG2", "IG3"],
|
||||
},
|
||||
status: "PASS",
|
||||
providers: { azure: "PASS", gcp: "PASS" },
|
||||
check_ids_by_provider: {
|
||||
azure: ["azure_check_a"],
|
||||
gcp: ["gcp_check_a"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("cis-controls mapper — cross-provider augmentation", () => {
|
||||
it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => {
|
||||
// This is the exact gap that made the per-provider check grouping and
|
||||
// the cross-provider findings fetch silently fall back to per-scan
|
||||
// behaviour for CIS Controls 8.1: only the CSA mapper carried these
|
||||
// fields until this fix.
|
||||
const { attributesData, requirementsData } = crossProviderToMapperInput(
|
||||
CROSS_PROVIDER_ATTRIBUTES,
|
||||
);
|
||||
const frameworks = mapComplianceData(attributesData, requirementsData);
|
||||
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirement.providers).toEqual({ azure: "PASS", gcp: "PASS" });
|
||||
expect(requirement.check_ids_by_provider).toEqual({
|
||||
azure: ["azure_check_a"],
|
||||
gcp: ["gcp_check_a"],
|
||||
});
|
||||
expect(requirement.scan_ids_by_provider).toEqual({
|
||||
azure: ["scan-azure-uuid"],
|
||||
gcp: ["scan-gcp-uuid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the augmentation fields undefined for a plain per-scan requirement", () => {
|
||||
const perScanAttributesData = {
|
||||
data: [
|
||||
{
|
||||
type: "compliance-requirements-attributes" as const,
|
||||
id: "1.1",
|
||||
attributes: {
|
||||
framework_description: "",
|
||||
name: "Establish and Maintain Detailed Enterprise Asset Inventory",
|
||||
framework: "CIS-Controls",
|
||||
version: "8.1",
|
||||
description: "",
|
||||
attributes: {
|
||||
metadata: [
|
||||
{
|
||||
Section: "1. Inventory and Control of Enterprise Assets",
|
||||
Function: "Identify",
|
||||
AssetType: "Devices",
|
||||
ImplementationGroups: ["IG1"],
|
||||
},
|
||||
],
|
||||
check_ids: ["azure_check_a"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const perScanRequirementsData = {
|
||||
data: [
|
||||
{
|
||||
type: "compliance-requirements-details" as const,
|
||||
id: "1.1",
|
||||
attributes: {
|
||||
framework: "CIS-Controls",
|
||||
version: "8.1",
|
||||
description: "",
|
||||
status: "PASS" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const frameworks = mapComplianceData(
|
||||
perScanAttributesData as any,
|
||||
|
||||
perScanRequirementsData as any,
|
||||
);
|
||||
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirement.providers).toBeUndefined();
|
||||
expect(requirement.check_ids_by_provider).toBeUndefined();
|
||||
expect(requirement.scan_ids_by_provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards the requirement's providers map into the accordion title", () => {
|
||||
const { attributesData, requirementsData } = crossProviderToMapperInput(
|
||||
CROSS_PROVIDER_ATTRIBUTES,
|
||||
);
|
||||
const frameworks = mapComplianceData(attributesData, requirementsData);
|
||||
const items = toAccordionItems(frameworks, "");
|
||||
const titleElement = items[0]?.items?.[0]?.title;
|
||||
|
||||
expect(isValidElement(titleElement)).toBe(true);
|
||||
if (isValidElement(titleElement)) {
|
||||
expect((titleElement.props as { providers?: unknown }).providers).toEqual(
|
||||
{ azure: "PASS", gcp: "PASS" },
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge";
|
||||
import {
|
||||
AttributesData,
|
||||
CISControlsAttributesMetadata,
|
||||
CrossProviderRequirement,
|
||||
Framework,
|
||||
Requirement,
|
||||
REQUIREMENT_STATUS,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
@@ -62,6 +62,14 @@ export const mapComplianceData = (
|
||||
const description = attributeItem.attributes.description;
|
||||
const status = requirementData.attributes.status || "";
|
||||
const checks = attributeItem.attributes.attributes.check_ids || [];
|
||||
// Optional cross-provider augmentations (only present when the attribute
|
||||
// item came from ``crossProviderToMapperInput``).
|
||||
const providersForRequirement =
|
||||
attributeItem.attributes.attributes.providers;
|
||||
const checkIdsByProvider =
|
||||
attributeItem.attributes.attributes.check_ids_by_provider;
|
||||
const scanIdsByProvider =
|
||||
attributeItem.attributes.attributes.scan_ids_by_provider;
|
||||
|
||||
const framework = findOrCreateFramework(frameworks, frameworkName);
|
||||
const category = findOrCreateCategory(framework.categories, categoryName);
|
||||
@@ -69,7 +77,7 @@ export const mapComplianceData = (
|
||||
const control = findOrCreateControl(category.controls, categoryName);
|
||||
|
||||
const finalStatus: RequirementStatus = status as RequirementStatus;
|
||||
const requirement: Requirement = {
|
||||
const requirement: CrossProviderRequirement = {
|
||||
name: requirementName ? `${id} - ${requirementName}` : id,
|
||||
description,
|
||||
status: finalStatus,
|
||||
@@ -78,6 +86,9 @@ export const mapComplianceData = (
|
||||
function: attrs.Function ?? undefined,
|
||||
asset_type: attrs.AssetType ?? undefined,
|
||||
implementation_groups: attrs.ImplementationGroups ?? undefined,
|
||||
providers: providersForRequirement,
|
||||
check_ids_by_provider: checkIdsByProvider,
|
||||
scan_ids_by_provider: scanIdsByProvider,
|
||||
};
|
||||
|
||||
control.requirements.push(requirement);
|
||||
@@ -122,6 +133,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
providers={requirement.providers}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("computeCrossProviderInsights", () => {
|
||||
expect(insights.total).toBe(5);
|
||||
});
|
||||
|
||||
it("builds providerCoverage for every compatible provider, including non-contributing ones", () => {
|
||||
it("builds providerCoverage for every scanned provider, contributing or not", () => {
|
||||
const insights = computeCrossProviderInsights(buildAttributes());
|
||||
const byKey = Object.fromEntries(
|
||||
insights.providerCoverage.map((c) => [c.key, c]),
|
||||
@@ -99,14 +99,38 @@ describe("computeCrossProviderInsights", () => {
|
||||
expect(byKey.azure.total).toBe(2);
|
||||
expect(byKey.azure.scorePercent).toBe(100);
|
||||
|
||||
// GCP is in compatible_providers but the API marks it as
|
||||
// non-contributing — surfaces with zeroed counts so the panel can
|
||||
// dim it instead of hiding it.
|
||||
// GCP HAS a scan (it's in scan_ids_by_provider) but the API marks it
|
||||
// non-contributing (no requirement row) — it still surfaces (scanned),
|
||||
// with zeroed counts so the panel can dim it.
|
||||
expect(byKey.gcp.contributing).toBe(false);
|
||||
expect(byKey.gcp.total).toBe(0);
|
||||
expect(byKey.gcp.scorePercent).toBe(0);
|
||||
});
|
||||
|
||||
it("shows only scanned providers in the detail (hides compatible providers with no scan)", () => {
|
||||
const noGcpScan: CrossProviderComplianceOverviewAttributes = {
|
||||
...buildAttributes(),
|
||||
// gcp stays compatible but is NOT scanned (dropped from scan_ids_by_provider).
|
||||
providers: ["aws", "azure"],
|
||||
scan_ids_by_provider: { aws: ["scan-aws"], azure: ["scan-azure"] },
|
||||
scan_ids: ["scan-aws", "scan-azure"],
|
||||
};
|
||||
const insights = computeCrossProviderInsights(noGcpScan);
|
||||
|
||||
// Detail surfaces (coverage panel, heatmap columns) only include scanned.
|
||||
expect(insights.scannedProviders).toEqual(["aws", "azure"]);
|
||||
expect(insights.providerCoverage.map((c) => c.key)).toEqual([
|
||||
"aws",
|
||||
"azure",
|
||||
]);
|
||||
for (const domain of insights.domainStats) {
|
||||
expect(Object.keys(domain.byProvider)).not.toContain("gcp");
|
||||
}
|
||||
|
||||
// ...but the full compatible set is still reported (the overview uses it).
|
||||
expect(insights.compatibleProviders).toContain("gcp");
|
||||
});
|
||||
|
||||
it("aggregates per-domain stats by Section attribute, with an Other bucket fallback", () => {
|
||||
const insights = computeCrossProviderInsights(buildAttributes());
|
||||
const byName = Object.fromEntries(
|
||||
@@ -183,7 +207,7 @@ describe("computeCrossProviderInsights", () => {
|
||||
expect(insights.scorePercent).toBe(0);
|
||||
expect(insights.domainStats).toEqual([]);
|
||||
expect(insights.domainsByFailCount).toEqual([]);
|
||||
// Compatible providers still surface (empty stats) so the coverage
|
||||
// Scanned providers still surface (empty stats) so the coverage
|
||||
// panel doesn't disappear.
|
||||
expect(insights.providerCoverage).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -62,9 +62,14 @@ export interface CrossProviderInsights {
|
||||
compatibleProviders: string[];
|
||||
/** Provider keys that contributed at least one row. */
|
||||
contributingProviders: string[];
|
||||
/** One coverage entry per compatible provider (always present, even
|
||||
* for non-contributing ones — coverage UI dims them rather than
|
||||
* hiding them). */
|
||||
/** Providers we actually have scans for (a subset of
|
||||
* ``compatibleProviders`` — the ones present in ``scan_ids_by_provider``),
|
||||
* in display order. The detail view shows only these; the overview keeps
|
||||
* showing every compatible provider (scanned or not). */
|
||||
scannedProviders: string[];
|
||||
/** One coverage entry per SCANNED provider. (The overview lists every
|
||||
* compatible provider and dims the unscanned ones; the detail view
|
||||
* intentionally hides providers with no scan.) */
|
||||
providerCoverage: ProviderCoverage[];
|
||||
/** Domain stats keyed by section name, in declared order. */
|
||||
domainStats: DomainStats[];
|
||||
@@ -163,7 +168,16 @@ export const computeCrossProviderInsights = (
|
||||
}
|
||||
|
||||
const contributingSet = new Set(contributing);
|
||||
const providerCoverage: ProviderCoverage[] = compatible.map((key) => {
|
||||
|
||||
// Providers we actually have scans for. The detail view shows only these
|
||||
// (framework-compatible providers with no scan belong on the overview,
|
||||
// where they're listed dimmed as "no scan yet"). Kept in ``compatible``
|
||||
// display order for a stable layout; a scan can only ever exist for a
|
||||
// compatible provider, so filtering ``compatible`` covers every case.
|
||||
const scannedSet = new Set(Object.keys(scanIdsByProvider ?? {}));
|
||||
const scannedProviders = compatible.filter((key) => scannedSet.has(key));
|
||||
|
||||
const providerCoverage: ProviderCoverage[] = scannedProviders.map((key) => {
|
||||
const total = providerTotal.get(key) || 0;
|
||||
const pass = providerPass.get(key) || 0;
|
||||
const scorePct = total > 0 ? Math.floor((pass / total) * 100) : 0;
|
||||
@@ -183,7 +197,7 @@ export const computeCrossProviderInsights = (
|
||||
const domainStats: DomainStats[] = Array.from(domainAcc.entries()).map(
|
||||
([name, acc]) => {
|
||||
const byProvider: Record<string, DomainProviderStatus> = {};
|
||||
for (const providerKey of compatible) {
|
||||
for (const providerKey of scannedProviders) {
|
||||
const statuses = acc.perProvider.get(providerKey) ?? [];
|
||||
byProvider[providerKey] = aggregateDomainProviderStatuses(statuses);
|
||||
}
|
||||
@@ -208,6 +222,7 @@ export const computeCrossProviderInsights = (
|
||||
total,
|
||||
compatibleProviders: compatible,
|
||||
contributingProviders: contributing,
|
||||
scannedProviders,
|
||||
providerCoverage,
|
||||
domainStats,
|
||||
domainsByFailCount,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { isValidElement } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// dora.tsx's accordion builders transitively import the client accordion
|
||||
// chain, which pulls in server-only code (next-auth) when loaded under
|
||||
// vitest. Stub them the same way compliance-mapper.test.ts does — we're
|
||||
// testing the mapper's data wiring, not these components' rendering.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({ ClientAccordionContent: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
|
||||
() => ({ ComplianceAccordionRequirementTitle: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({ ComplianceAccordionTitle: () => null }),
|
||||
);
|
||||
|
||||
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
|
||||
|
||||
import { crossProviderToMapperInput } from "./cross-provider-adapter";
|
||||
import { mapComplianceData, toAccordionItems } from "./dora";
|
||||
|
||||
const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = {
|
||||
compliance_id: "dora_2022_2554",
|
||||
framework: "DORA",
|
||||
name: "DORA",
|
||||
version: "2022/2554",
|
||||
description: "Digital Operational Resilience Act",
|
||||
compatible_providers: ["aws", "azure"],
|
||||
requested_providers: ["aws", "azure"],
|
||||
providers: ["aws", "azure"],
|
||||
scan_ids: ["scan-aws-uuid", "scan-azure-uuid"],
|
||||
scan_ids_by_provider: {
|
||||
aws: ["scan-aws-uuid"],
|
||||
azure: ["scan-azure-uuid"],
|
||||
},
|
||||
requirements_passed: 1,
|
||||
requirements_failed: 0,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 1,
|
||||
requirements: [
|
||||
{
|
||||
id: "Art-5",
|
||||
name: "ICT risk management framework",
|
||||
description: "Maintain a sound ICT risk management framework.",
|
||||
attributes: {
|
||||
Pillar: "ICT Risk Management",
|
||||
Article: "5",
|
||||
ArticleTitle: "ICT risk management framework",
|
||||
},
|
||||
status: "PASS",
|
||||
providers: { aws: "PASS", azure: "PASS" },
|
||||
check_ids_by_provider: {
|
||||
aws: ["aws_check_a"],
|
||||
azure: ["azure_check_a"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("dora mapper — cross-provider augmentation", () => {
|
||||
it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => {
|
||||
// Same gap fixed for CIS Controls: only the CSA mapper carried these
|
||||
// fields until this fix, silently breaking per-provider check grouping
|
||||
// and the cross-provider findings fetch for DORA.
|
||||
const { attributesData, requirementsData } = crossProviderToMapperInput(
|
||||
CROSS_PROVIDER_ATTRIBUTES,
|
||||
);
|
||||
const frameworks = mapComplianceData(attributesData, requirementsData);
|
||||
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirement.providers).toEqual({ aws: "PASS", azure: "PASS" });
|
||||
expect(requirement.check_ids_by_provider).toEqual({
|
||||
aws: ["aws_check_a"],
|
||||
azure: ["azure_check_a"],
|
||||
});
|
||||
expect(requirement.scan_ids_by_provider).toEqual({
|
||||
aws: ["scan-aws-uuid"],
|
||||
azure: ["scan-azure-uuid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the augmentation fields undefined for a plain per-scan requirement", () => {
|
||||
const perScanAttributesData = {
|
||||
data: [
|
||||
{
|
||||
type: "compliance-requirements-attributes" as const,
|
||||
id: "Art-5",
|
||||
attributes: {
|
||||
framework_description: "",
|
||||
name: "ICT risk management framework",
|
||||
framework: "DORA",
|
||||
version: "2022/2554",
|
||||
description: "",
|
||||
attributes: {
|
||||
metadata: [
|
||||
{
|
||||
Pillar: "ICT Risk Management",
|
||||
Article: "5",
|
||||
ArticleTitle: "ICT risk management framework",
|
||||
},
|
||||
],
|
||||
check_ids: ["aws_check_a"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const perScanRequirementsData = {
|
||||
data: [
|
||||
{
|
||||
type: "compliance-requirements-details" as const,
|
||||
id: "Art-5",
|
||||
attributes: {
|
||||
framework: "DORA",
|
||||
version: "2022/2554",
|
||||
description: "",
|
||||
status: "PASS" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const frameworks = mapComplianceData(
|
||||
perScanAttributesData as any,
|
||||
|
||||
perScanRequirementsData as any,
|
||||
);
|
||||
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirement.providers).toBeUndefined();
|
||||
expect(requirement.check_ids_by_provider).toBeUndefined();
|
||||
expect(requirement.scan_ids_by_provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards the requirement's providers map into the accordion title", () => {
|
||||
const { attributesData, requirementsData } = crossProviderToMapperInput(
|
||||
CROSS_PROVIDER_ATTRIBUTES,
|
||||
);
|
||||
const frameworks = mapComplianceData(attributesData, requirementsData);
|
||||
const items = toAccordionItems(frameworks, "");
|
||||
const titleElement = items[0]?.items?.[0]?.title;
|
||||
|
||||
expect(isValidElement(titleElement)).toBe(true);
|
||||
if (isValidElement(titleElement)) {
|
||||
expect((titleElement.props as { providers?: unknown }).providers).toEqual(
|
||||
{ aws: "PASS", azure: "PASS" },
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
||||
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
|
||||
import {
|
||||
AttributesData,
|
||||
CrossProviderRequirement,
|
||||
DORAAttributesMetadata,
|
||||
Framework,
|
||||
Requirement,
|
||||
REQUIREMENT_STATUS,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
@@ -65,6 +65,14 @@ export const mapComplianceData = (
|
||||
const description = attributeItem.attributes.description;
|
||||
const status = requirementData.attributes.status || "";
|
||||
const checks = attributeItem.attributes.attributes.check_ids || [];
|
||||
// Optional cross-provider augmentations (only present when the attribute
|
||||
// item came from ``crossProviderToMapperInput``).
|
||||
const providersForRequirement =
|
||||
attributeItem.attributes.attributes.providers;
|
||||
const checkIdsByProvider =
|
||||
attributeItem.attributes.attributes.check_ids_by_provider;
|
||||
const scanIdsByProvider =
|
||||
attributeItem.attributes.attributes.scan_ids_by_provider;
|
||||
|
||||
const framework = findOrCreateFramework(frameworks, frameworkName);
|
||||
const category = findOrCreateCategory(framework.categories, categoryName);
|
||||
@@ -72,7 +80,7 @@ export const mapComplianceData = (
|
||||
const control = findOrCreateControl(category.controls, categoryName);
|
||||
|
||||
const finalStatus: RequirementStatus = status as RequirementStatus;
|
||||
const requirement: Requirement = {
|
||||
const requirement: CrossProviderRequirement = {
|
||||
name: requirementName ? `${id} - ${requirementName}` : id,
|
||||
description,
|
||||
status: finalStatus,
|
||||
@@ -82,6 +90,9 @@ export const mapComplianceData = (
|
||||
pillar: attrs.Pillar,
|
||||
article: attrs.Article,
|
||||
article_title: attrs.ArticleTitle,
|
||||
providers: providersForRequirement,
|
||||
check_ids_by_provider: checkIdsByProvider,
|
||||
scan_ids_by_provider: scanIdsByProvider,
|
||||
};
|
||||
|
||||
control.requirements.push(requirement);
|
||||
@@ -135,6 +146,7 @@ export const toAccordionItems = (
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
providers={requirement.providers}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getProwlerHubComplianceUrl } from "./prowler-hub";
|
||||
|
||||
describe("getProwlerHubComplianceUrl", () => {
|
||||
it("builds the Prowler Hub compliance page URL from the framework's compliance_id", () => {
|
||||
expect(getProwlerHubComplianceUrl("cis_controls_8.1")).toBe(
|
||||
"https://hub.prowler.com/compliance/cis_controls_8.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("works for every universal framework in the catalogue", () => {
|
||||
expect(getProwlerHubComplianceUrl("csa_ccm_4.0")).toBe(
|
||||
"https://hub.prowler.com/compliance/csa_ccm_4.0",
|
||||
);
|
||||
expect(getProwlerHubComplianceUrl("dora_2022_2554")).toBe(
|
||||
"https://hub.prowler.com/compliance/dora_2022_2554",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const PROWLER_HUB_BASE_URL = "https://hub.prowler.com/compliance";
|
||||
|
||||
/**
|
||||
* Prowler Hub page for a universal compliance framework, keyed by its
|
||||
* ``compliance_id`` (e.g. ``cis_controls_8.1``) — same id the cross-provider
|
||||
* overview endpoint and ``UNIVERSAL_FRAMEWORKS`` catalogue already use, so no
|
||||
* separate mapping is needed per framework.
|
||||
*/
|
||||
export const getProwlerHubComplianceUrl = (complianceId: string): string =>
|
||||
`${PROWLER_HUB_BASE_URL}/${complianceId}`;
|
||||
+8
-2
@@ -188,9 +188,15 @@ export const downloadScanZip = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic function to download a file from base64 data
|
||||
* Generic function to download a file from base64 data.
|
||||
*
|
||||
* Exported (not just used internally by `downloadCompliancePdf`/`downloadComplianceCsv`
|
||||
* /`downloadComplianceOcsf`) so other binary-download flows built on the
|
||||
* same `ScanBinaryResult`-shaped server action result — e.g. the ad-hoc
|
||||
* cross-provider compliance PDF — can reuse the exact same base64→Blob→click
|
||||
* download mechanics instead of re-implementing them.
|
||||
*/
|
||||
const downloadFile = async (
|
||||
export const downloadFile = async (
|
||||
result: ScanBinaryResult,
|
||||
outputType: string,
|
||||
successMessage: string,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { getProviderBadge, getProviderLabel } from "./provider-display";
|
||||
|
||||
describe("getProviderBadge", () => {
|
||||
it("resolves a badge for every provider added to close the CIS Controls 8.1 icon gap", () => {
|
||||
// github/googleworkspace/okta/cloudflare/mongodbatlas/openstack/vercel
|
||||
// were previously missing from PROVIDER_BADGE_BY_KEY, rendering blank
|
||||
// squares wherever a badge was expected.
|
||||
const withIcons = [
|
||||
"github",
|
||||
"googleworkspace",
|
||||
"okta",
|
||||
"cloudflare",
|
||||
"mongodbatlas",
|
||||
"openstack",
|
||||
"vercel",
|
||||
];
|
||||
for (const key of withIcons) {
|
||||
expect(getProviderBadge(key), key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for providers that only have a fallback label", () => {
|
||||
// linode/stackit/nhn/scaleway are declared by the SDK's universal
|
||||
// compliance templates but have no dedicated icon component yet —
|
||||
// ProviderBadgeIcon depends on this returning undefined to know when
|
||||
// to render its initials fallback instead.
|
||||
for (const key of ["linode", "stackit", "nhn", "scaleway"]) {
|
||||
expect(getProviderBadge(key), key).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for an entirely unknown provider key", () => {
|
||||
expect(getProviderBadge("made-up-provider")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderLabel", () => {
|
||||
it("reuses the shared PROVIDER_DISPLAY_NAMES map instead of a divergent local copy", () => {
|
||||
// Regression guard: an earlier version of this module hand-copied
|
||||
// labels and drifted from types/providers.ts (e.g. "GCP" vs "Google
|
||||
// Cloud", "Oracle Cloud" vs "Oracle Cloud Infrastructure"), producing a
|
||||
// different label for the same provider on different parts of the same
|
||||
// page. Every known ProviderType must match the shared map exactly.
|
||||
for (const [key, label] of Object.entries(PROVIDER_DISPLAY_NAMES)) {
|
||||
expect(getProviderLabel(key), key).toBe(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels backend-only providers not yet onboarded as a ProviderType", () => {
|
||||
expect(getProviderLabel("linode")).toBe("Linode");
|
||||
expect(getProviderLabel("stackit")).toBe("STACKIT");
|
||||
expect(getProviderLabel("nhn")).toBe("NHN Cloud");
|
||||
expect(getProviderLabel("scaleway")).toBe("Scaleway");
|
||||
});
|
||||
|
||||
it("falls back to the uppercased key for an entirely unknown provider", () => {
|
||||
expect(getProviderLabel("made-up-provider")).toBe("MADE-UP-PROVIDER");
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,20 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import type { IconSvgProps } from "@/types/components";
|
||||
import { PROVIDER_DISPLAY_NAMES, type ProviderType } from "@/types/providers";
|
||||
|
||||
/**
|
||||
* Single source of truth for provider display metadata. Kept in
|
||||
@@ -29,21 +37,35 @@ export const PROVIDER_BADGE_BY_KEY: Record<string, FC<IconSvgProps>> = {
|
||||
oraclecloud: OracleCloudProviderBadge,
|
||||
kubernetes: KS8ProviderBadge,
|
||||
m365: M365ProviderBadge,
|
||||
github: GitHubProviderBadge,
|
||||
googleworkspace: GoogleWorkspaceProviderBadge,
|
||||
okta: OktaProviderBadge,
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
mongodbatlas: MongoDBAtlasProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
vercel: VercelProviderBadge,
|
||||
};
|
||||
|
||||
export const PROVIDER_LABEL_BY_KEY: Record<string, string> = {
|
||||
aws: "AWS",
|
||||
azure: "Azure",
|
||||
gcp: "GCP",
|
||||
alibabacloud: "Alibaba Cloud",
|
||||
oraclecloud: "Oracle Cloud",
|
||||
kubernetes: "Kubernetes",
|
||||
m365: "Microsoft 365",
|
||||
/**
|
||||
* Labels for providers the SDK's universal compliance templates can declare
|
||||
* (e.g. cross-provider CIS Controls) that aren't onboarded as a
|
||||
* ``ProviderType`` yet — no icon, account selector, or scan config exists
|
||||
* for them, so they only ever reach ``getProviderLabel``'s fallback badge.
|
||||
* Real providers must NOT be duplicated here: ``PROVIDER_DISPLAY_NAMES`` in
|
||||
* ``@/types/providers`` is the single source of truth for those.
|
||||
*/
|
||||
const FALLBACK_ONLY_PROVIDER_LABELS: Record<string, string> = {
|
||||
linode: "Linode",
|
||||
stackit: "STACKIT",
|
||||
nhn: "NHN Cloud",
|
||||
scaleway: "Scaleway",
|
||||
};
|
||||
|
||||
/** Resolve a provider label, falling back to an uppercased key. */
|
||||
export const getProviderLabel = (providerKey: string): string =>
|
||||
PROVIDER_LABEL_BY_KEY[providerKey] ?? providerKey.toUpperCase();
|
||||
PROVIDER_DISPLAY_NAMES[providerKey as ProviderType] ??
|
||||
FALLBACK_ONLY_PROVIDER_LABELS[providerKey] ??
|
||||
providerKey.toUpperCase();
|
||||
|
||||
/** Resolve the badge component, returning ``undefined`` when no icon
|
||||
* is registered for the given key (the consumer renders a fallback). */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/** A single in-flight (or just-finished) cross-provider PDF generation. */
|
||||
export interface CrossProviderPdfGeneration {
|
||||
/** The generation task id returned by the server action. */
|
||||
taskId: string;
|
||||
/**
|
||||
* The filter signature the generation was started under (framework +
|
||||
* scan ids + provider filters). A ``GeneratePdfButton`` only offers a
|
||||
* finished report for download when its own current signature matches
|
||||
* this — a report generated under the previous filters must never be
|
||||
* served under new ones.
|
||||
*/
|
||||
signature: string;
|
||||
/**
|
||||
* Absolute path + search of the page the generation was started from, so
|
||||
* the "ready" toast can send the user back to exactly where they
|
||||
* generated it — even if they navigated away while it was rendering.
|
||||
*/
|
||||
reportUrl: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
}
|
||||
|
||||
interface CrossProviderPdfState {
|
||||
/** Tracked generations keyed by task id. */
|
||||
generations: Record<string, CrossProviderPdfGeneration>;
|
||||
/** Register a newly started generation as ``running``. */
|
||||
trackGeneration: (entry: Omit<CrossProviderPdfGeneration, "status">) => void;
|
||||
markCompleted: (taskId: string) => void;
|
||||
markFailed: (taskId: string) => void;
|
||||
/** Forget a generation entirely (e.g. its file expired on download). */
|
||||
removeGeneration: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const setStatus = (
|
||||
generations: Record<string, CrossProviderPdfGeneration>,
|
||||
taskId: string,
|
||||
status: CrossProviderPdfGeneration["status"],
|
||||
): Record<string, CrossProviderPdfGeneration> => {
|
||||
const entry = generations[taskId];
|
||||
if (!entry || entry.status === status) return generations;
|
||||
return { ...generations, [taskId]: { ...entry, status } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Global registry of in-flight cross-provider PDF generations.
|
||||
*
|
||||
* The generation job has no fixed time budget, so completion is detected by
|
||||
* client-side polling. That polling lives in a single ``CrossProviderPdfWatcher``
|
||||
* mounted once in the app layout (NOT in the ``GeneratePdfButton``) so the
|
||||
* "ready" notification still fires after the user navigates away from the
|
||||
* page they generated the report on — the button unmounts, but the watcher
|
||||
* and this store persist across route changes.
|
||||
*
|
||||
* In-memory only (like the other UI stores): a full browser reload mid-
|
||||
* generation drops tracking, which is acceptable — the reported gap is SPA
|
||||
* navigation within the app, where the store stays alive.
|
||||
*/
|
||||
export const useCrossProviderPdfStore = create<CrossProviderPdfState>(
|
||||
(set) => ({
|
||||
generations: {},
|
||||
trackGeneration: (entry) =>
|
||||
set((state) => ({
|
||||
generations: {
|
||||
...state.generations,
|
||||
[entry.taskId]: { ...entry, status: "running" },
|
||||
},
|
||||
})),
|
||||
markCompleted: (taskId) =>
|
||||
set((state) => ({
|
||||
generations: setStatus(state.generations, taskId, "completed"),
|
||||
})),
|
||||
markFailed: (taskId) =>
|
||||
set((state) => ({
|
||||
generations: setStatus(state.generations, taskId, "failed"),
|
||||
})),
|
||||
removeGeneration: (taskId) =>
|
||||
set((state) => {
|
||||
if (!state.generations[taskId]) return state;
|
||||
const next = { ...state.generations };
|
||||
delete next[taskId];
|
||||
return { generations: next };
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./cross-provider-pdf/store";
|
||||
export * from "./organizations/store";
|
||||
export * from "./provider-wizard/store";
|
||||
export * from "./scans/store";
|
||||
|
||||
Reference in new issue
Block a user