chore(coderabbitai): add missing changes

This commit is contained in:
pedrooot committed 2026-07-07 10:56:33 +02:00
1 parent 64fa8782d5
commit 2c3772bc80
21 files changed
+474 -238

No files matched your search

+34
View File
@@ -1,5 +1,7 @@
"use server";
import * as Sentry from "@sentry/nextjs";
import type { ScanBinaryResult } from "@/actions/scans";
import {
apiBaseUrl,
@@ -8,6 +10,7 @@ import {
getErrorMessage,
} from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
async function getCrossProviderPdfErrorMessage(
response: Response,
@@ -21,6 +24,37 @@ async function getCrossProviderPdfErrorMessage(
const errorData = await response.json().catch(() => null);
// Report unexpected server/parse failures to Sentry — the returned message
// stays sanitized (callers pass it through ``getErrorMessage``), but these
// PDF generate/download/latest endpoints would otherwise go unmonitored,
// unlike everything routed through ``handleApiResponse``.
if (response.status >= 500 || errorData === null) {
Sentry.captureException(
new Error(
`Cross-provider PDF request failed (${response.status}): ${response.statusText}`,
),
{
tags: {
api_error: true,
status_code: response.status.toString(),
error_type:
response.status >= 500
? SentryErrorType.SERVER_ERROR
: SentryErrorType.REQUEST_PROCESSING,
error_source: SentryErrorSource.SERVER_ACTION,
},
level: response.status >= 500 ? "error" : "warning",
contexts: {
api_response: {
status: response.status,
statusText: response.statusText,
url: response.url,
},
},
},
);
}
return (
errorData?.errors?.[0]?.detail ||
errorData?.errors?.detail ||
@@ -39,6 +39,7 @@ import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
} from "@/lib/compliance/compliance-report-types";
import { UNIVERSAL_FRAMEWORKS } from "@/lib/compliance/universal-frameworks";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import {
@@ -121,6 +122,16 @@ export default async function ComplianceDetail({
]);
if (!crossProviderResponse || "redirectTo" in crossProviderResponse) {
// A 402 (payment required) resolves to ``{ redirectTo: "/billing" }`` —
// send the user to the upgrade prompt instead of the generic
// "not available" state, which would swallow the billing signal.
if (
crossProviderResponse &&
"redirectTo" in crossProviderResponse &&
crossProviderResponse.redirectTo
) {
redirect(crossProviderResponse.redirectTo);
}
return (
<ContentLayout title={crossProviderTitle}>
<Alert variant="info">
@@ -153,8 +164,19 @@ export default async function ComplianceDetail({
}
const headerTitle = crossProviderData.attributes.name || crossProviderTitle;
// ``compatible_providers`` from the API is authoritative; fall back to the
// ``UNIVERSAL_FRAMEWORKS`` catalogue entry when it's missing or empty, so
// the detail view and provider filters stay populated — matching the
// overview grid's behavior instead of dropping every provider.
const apiCompatibleProviders =
crossProviderData.attributes.compatible_providers;
const catalogueProviders =
UNIVERSAL_FRAMEWORKS.find((entry) => entry.id === complianceId)
?.providers ?? [];
const compatibleProviderTypes = new Set(
crossProviderData.attributes.compatible_providers,
apiCompatibleProviders && apiCompatibleProviders.length > 0
? apiCompatibleProviders
: catalogueProviders,
);
const compatibleProviders = (providersData?.data || []).filter((provider) =>
compatibleProviderTypes.has(provider.attributes.provider),
+18 -1
View File
@@ -1,4 +1,5 @@
import { Info } from "lucide-react";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
@@ -351,8 +352,19 @@ const SSRCrossProviderGrid = async ({
);
const summaries: CrossProviderFrameworkSummary[] = [];
// A 402 (payment required) resolves to ``{ redirectTo: "/billing" }``.
// Cross-provider is a subscription-gated Cloud feature, so the gate applies
// tenant-wide — every framework returns the same redirect. Capture it and,
// if it leaves us with nothing to render, send the user to the upgrade
// prompt instead of the generic empty state.
let billingRedirect: string | null = null;
for (const { entry, response } of responses) {
if (!response || "redirectTo" in response) continue;
if (!response || "redirectTo" in response) {
if (response && "redirectTo" in response && response.redirectTo) {
billingRedirect = response.redirectTo;
}
continue;
}
const data = (response as { data?: CrossProviderComplianceOverviewData })
.data;
if (!data) {
@@ -391,6 +403,11 @@ const SSRCrossProviderGrid = async ({
}
if (summaries.length === 0) {
// Nothing to show and the only responses were billing-gated → surface the
// upgrade prompt rather than the generic "nothing available" message.
if (billingRedirect) {
redirect(billingRedirect);
}
return (
<ComplianceOverviewPanel>
<Alert variant="info">
@@ -2,7 +2,6 @@
import { AlertTriangle } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useMemo } from "react";
import {
loadLatestFindingTriageNote,
@@ -88,6 +87,7 @@ export const ClientAccordionContent = ({
expandedFindings,
isLoading,
error,
isPartial,
patchTriageUpdate,
reload,
} = useRequirementFindings({
@@ -124,7 +124,7 @@ export const ClientAccordionContent = ({
// Per-provider finding tallies for the cross-provider breakdown. Derived
// from the merged ``findings`` (mapping each row to its provider via
// ``scan_ids_by_provider``) so the counts always match the unified table.
const providerFindingStats = useMemo(() => {
const providerFindingStats = (() => {
const count: Record<string, number> = {};
const pass: Record<string, number> = {};
const fail: Record<string, number> = {};
@@ -150,7 +150,7 @@ export const ClientAccordionContent = ({
else if (status === "FAIL") fail[providerKey] += 1;
}
return { count, pass, fail };
}, [findings, isCrossProvider, scanIdsByProvider]);
})();
const renderDetails = () => {
if (!complianceId) {
@@ -175,16 +175,20 @@ export const ClientAccordionContent = ({
const findingsLoaded = findings !== null;
return (
<div className="my-4">
<h4 className="mb-2 text-sm font-medium">Per-Provider Breakdown</h4>
<div className="border-border-neutral-secondary overflow-hidden rounded-md border">
<h4 className="text-text-neutral-secondary mb-2 text-[11px] font-semibold tracking-wider uppercase">
Per-Provider Breakdown
</h4>
<div className="border-border-neutral-secondary overflow-hidden rounded-lg border">
<table className="w-full text-xs">
<thead className="bg-default-100">
<thead className="bg-default-100/60">
<tr className="text-text-neutral-secondary text-left">
<th className="px-3 py-2 font-semibold">Provider</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Findings</th>
<th className="px-3 py-2 font-semibold">Pass / Fail</th>
<th className="px-3 py-2 font-semibold">Scan ID</th>
<th className="px-3 py-2 font-medium">Provider</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Findings</th>
<th className="px-3 py-2 text-right font-medium">
Pass / Fail
</th>
<th className="px-3 py-2 font-medium">Scans</th>
</tr>
</thead>
<tbody>
@@ -199,29 +203,35 @@ export const ClientAccordionContent = ({
return (
<tr
key={providerKey}
className="border-border-neutral-secondary border-t"
className="border-border-neutral-secondary hover:bg-default-100/40 border-t transition-colors"
>
<td className="px-3 py-2 align-top font-medium">
<div className="flex flex-col gap-0.5">
<span>{label}</span>
{accountCount > 1 && (
<span className="text-text-neutral-secondary text-[10px] tracking-wider uppercase">
{accountCount} accounts
</span>
)}
<td className="px-3 py-2.5 align-middle font-medium">
<div className="flex items-center gap-2">
<ProviderBadgeIcon
providerKey={providerKey}
size={18}
/>
<div className="flex flex-col leading-tight">
<span>{label}</span>
{accountCount > 1 && (
<span className="text-text-neutral-secondary text-[10px]">
{accountCount} accounts
</span>
)}
</div>
</div>
</td>
<td className="px-3 py-2 align-top">
<td className="px-3 py-2.5 align-middle">
<StatusFindingBadge
status={toFindingStatus(providerStatus)}
/>
</td>
<td className="text-text-neutral-secondary px-3 py-2 align-top">
<td className="text-text-neutral-secondary px-3 py-2.5 text-right align-middle tabular-nums">
{findingsLoaded ? (findingsCount ?? 0) : "—"}
</td>
<td className="px-3 py-2 align-top">
<td className="px-3 py-2.5 text-right align-middle">
{findingsLoaded ? (
<span className="font-mono">
<span className="font-mono tabular-nums">
<span className="text-bg-pass">{passCount}</span>
<span className="text-text-neutral-secondary">
{" / "}
@@ -232,18 +242,21 @@ export const ClientAccordionContent = ({
<span className="text-text-neutral-secondary">—</span>
)}
</td>
<td
className="text-text-neutral-secondary px-3 py-2 align-top font-mono text-[11px] break-all"
title={scanIdsForProvider.join("\n")}
>
<td className="px-3 py-2.5 align-middle">
{accountCount === 0 ? (
"—"
<span className="text-text-neutral-secondary">—</span>
) : (
<ul className="flex flex-col gap-0.5">
<div className="flex flex-col gap-1">
{scanIdsForProvider.map((sid) => (
<li key={sid}>{sid}</li>
<span
key={sid}
title="Click to select · scan id"
className="bg-default-100 text-text-neutral-secondary w-fit rounded px-1.5 py-0.5 font-mono text-[10px] leading-relaxed select-all"
>
{sid}
</span>
))}
</ul>
</div>
)}
</td>
</tr>
@@ -379,6 +392,26 @@ export const ClientAccordionContent = ({
<>
<h4 className="mb-2 text-sm font-medium">Findings</h4>
{isPartial && (
<Alert variant="warning" className="mb-3">
<AlertTriangle />
<AlertDescription className="flex flex-wrap items-center gap-2">
<span>
Some providers&apos; findings could not be loaded, so this
view may be incomplete.
</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
onClick={reload}
>
Try again
</Button>
</AlertDescription>
</Alert>
)}
<DataTable
columns={getStandaloneFindingColumns({
openFindingId,
@@ -3,6 +3,7 @@ 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 { getProviderLabel } from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
import { RequirementStatus } from "@/types/compliance";
interface ComplianceAccordionRequirementTitleProps {
@@ -63,10 +64,11 @@ export const ComplianceAccordionRequirementTitle = ({
<ProviderBadgeIcon providerKey={providerKey} size={12} />
<span className="text-[10px] font-semibold">{label}</span>
<span
className={`size-1.5 rounded-full ${
className={cn(
"size-1.5 rounded-full",
STATUS_DOT_CLASS_BY_STATUS[providerStatus] ??
"bg-text-neutral-secondary"
}`}
"bg-text-neutral-secondary",
)}
aria-label={providerStatus}
/>
</span>
@@ -46,6 +46,11 @@ interface UseRequirementFindingsReturn {
expandedFindings: FindingProps[];
isLoading: boolean;
error: string | null;
// Cross-provider only: true when at least one — but not every — per-scan
// request failed, so the merged view is missing some providers' findings.
// The caller surfaces a warning instead of presenting the partial data as
// complete.
isPartial: boolean;
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
reload: () => void;
}
@@ -108,6 +113,7 @@ export function useRequirementFindings({
const [findings, setFindings] = useState<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const [error, setError] = useState<string | null>(null);
const [isPartial, setIsPartial] = useState(false);
const [reloadNonce, setReloadNonce] = useState(0);
// Depend on the joined value, not the array: the requirement prop gets a
@@ -161,6 +167,7 @@ export function useRequirementFindings({
const loadFindings = async () => {
setError(null);
setIsPartial(false);
try {
const encodedSort = sort.replace(/^\+/, "");
@@ -196,6 +203,7 @@ export function useRequirementFindings({
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
pageSize: parseInt(pageSize, 10),
sort: encodedSort,
}),
),
@@ -203,12 +211,29 @@ export function useRequirementFindings({
if (cancelled) return;
// ``getFindings`` resolves to ``undefined`` on a failed request
// rather than throwing, so a per-scan failure would otherwise be
// silently dropped from the merge and the view would look complete.
const failedCount = responses.filter(
(r) => !r || typeof r !== "object" || !("data" in r),
).length;
// Every request failed — treat it as a hard error (same surface as
// the per-scan branch) instead of rendering an empty "no findings".
if (jobs.length > 0 && failedCount === jobs.length) {
setError(FINDINGS_LOAD_ERROR);
return;
}
const merged = mergeCrossProviderResponses(
responses,
parseInt(pageNumber, 10),
);
setFindings(merged);
expandFindings(merged);
// Some — but not all — scans failed: keep the successful data but
// flag the merge as incomplete so the caller can warn the user.
setIsPartial(failedCount > 0);
return;
}
@@ -275,6 +300,7 @@ export function useRequirementFindings({
expandedFindings,
isLoading,
error,
isPartial,
patchTriageUpdate,
reload,
};
@@ -1,63 +1,101 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
import { CrossProviderCard } from "./cross-provider-card";
const { pushMock, searchParams } = vi.hoisted(() => ({
pushMock: vi.fn(),
searchParams: new URLSearchParams(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
useSearchParams: () => searchParams,
}));
// ``next/image`` needs the App Router runtime; a stub is enough to assert the
// card's own wiring (the logo image itself isn't queried).
vi.mock("next/image", () => ({
default: ({ alt }: { alt?: string }) => <span aria-label={alt} />,
}));
const baseProps = {
complianceId: "cis_controls_8.1",
title: "CIS-Controls",
version: "8.1",
description: "A universal framework",
requirementsPassed: 5,
totalRequirements: 10,
contributingProviders: ["aws"],
compatibleProviders: ["aws", "azure"],
};
describe("CrossProviderCard", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "cross-provider-card.tsx");
const source = readFileSync(filePath, "utf8");
it("navigates to the universal detail page in cross-provider mode", () => {
// The drill-down must emit ?mode=cross-provider so the detail page knows
// to render the universal-aggregated view instead of the per-scan one.
expect(source).toContain('"mode"');
expect(source).toContain('"cross-provider"');
beforeEach(() => {
pushMock.mockClear();
searchParams.forEach((_, key) => searchParams.delete(key));
});
it("does not depend on a scanId prop", () => {
// The cross-provider tab must not require a scan picker.
expect(source).not.toMatch(/scanId/);
it("navigates to the universal detail page in cross-provider mode on click", () => {
render(<CrossProviderCard {...baseProps} />);
// The whole card is the click target — drill in from its title.
fireEvent.click(screen.getByText("CIS Controls - 8.1"));
expect(pushMock).toHaveBeenCalledTimes(1);
const target = pushMock.mock.calls[0][0] as string;
expect(target).toContain("/compliance/CIS-Controls");
expect(target).toContain("mode=cross-provider");
expect(target).toContain("complianceId=cis_controls_8.1");
});
it("is keyboard-activatable via Enter", () => {
render(<CrossProviderCard {...baseProps} />);
const card = screen.getAllByRole("button")[0];
fireEvent.keyDown(card, { key: "Enter" });
expect(pushMock).toHaveBeenCalledTimes(1);
});
it("preserves provider_type and region filters when drilling in", () => {
expect(source).toContain('"filter[region__in]"');
expect(source).toContain('"filter[provider_type__in]"');
searchParams.set("filter[region__in]", "eu-west-1");
searchParams.set("filter[provider_type__in]", "aws");
render(<CrossProviderCard {...baseProps} />);
fireEvent.click(screen.getByText("CIS Controls - 8.1"));
const target = pushMock.mock.calls[0][0] as string;
expect(target).toContain("filter%5Bregion__in%5D=eu-west-1");
expect(target).toContain("filter%5Bprovider_type__in%5D=aws");
});
it("surfaces the providers contribution ratio via chips", () => {
// The card renders one chip per compatible provider and dims the ones
// that did not contribute to the aggregation. The ratio is implicit in
// the active/inactive state of the chip set.
expect(source).toContain("contributingProviders");
expect(source).toContain("compatibleProviders");
expect(source).toContain("ProviderChip");
it("renders one chip per provider, marking contributing vs. not-yet-scanned", () => {
render(<CrossProviderCard {...baseProps} />);
// aws contributed a scan; azure is compatible but has no scan yet.
expect(screen.getByLabelText(/scan available/)).toBeInTheDocument();
expect(screen.getByLabelText(/no scan yet/)).toBeInTheDocument();
});
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 to the framework's Prowler Hub page in a new tab", () => {
render(<CrossProviderCard {...baseProps} />);
const hubLink = screen.getByRole("link", { name: /prowler hub/i });
expect(hubLink).toHaveAttribute(
"href",
getProwlerHubComplianceUrl(baseProps.complianceId),
);
expect(hubLink).toHaveAttribute("target", "_blank");
});
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("does not trigger the card's drill-down when the info button is clicked", () => {
render(<CrossProviderCard {...baseProps} />);
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()");
fireEvent.click(screen.getByRole("link", { name: /prowler hub/i }));
expect(pushMock).not.toHaveBeenCalled();
});
});
@@ -23,6 +23,8 @@ import {
import { getProviderLabel } from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
import { formatTitle } from "./utils";
export interface CrossProviderCardProps {
/** Universal framework id (e.g. ``csa_ccm_4.0``). Used as the
* ``filter[compliance_id]`` value when navigating to the detail page. */
@@ -44,8 +46,6 @@ export interface CrossProviderCardProps {
compatibleProviders: string[];
}
const formatTitle = (title: string) => title.split("-").join(" ");
const getRatingVariant = (value: number): ScoreColorVariant => {
if (value <= 10) return "danger";
if (value <= 40) return "warning";
@@ -86,7 +86,7 @@ const ProviderChip = ({ providerKey, active }: ProviderChipProps) => {
);
};
export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
export const CrossProviderCard = ({
complianceId,
title,
version,
@@ -95,7 +95,7 @@ export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
totalRequirements,
contributingProviders,
compatibleProviders,
}) => {
}: CrossProviderCardProps) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -141,6 +141,14 @@ export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
padding="md"
className="relative cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigateToDetail();
}
}}
>
<CardContent className="p-0">
<div className="flex w-full flex-col gap-3">
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
@@ -54,10 +54,7 @@ export const CrossProviderDetailClient = ({
providerGroupsFilter,
latestPdfReport,
}: CrossProviderDetailClientProps) => {
const insights = useMemo(
() => computeCrossProviderInsights(attributes),
[attributes],
);
const insights = computeCrossProviderInsights(attributes);
const accordionContainerRef = useRef<HTMLDivElement>(null);
const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -65,32 +62,37 @@ export const CrossProviderDetailClient = ({
string | null
>(null);
const handleDomainSelect = useCallback(
(domainName: string) => {
// Setting the forced key expands the target section. Selecting a domain
// is a user action, so the scroll + flash run here (not in a reactive
// effect): a single rAF lets the expansion commit before we locate the
// anchor and bring it into view.
setForcedExpandedSectionKey(`${attributes.framework}-${domainName}`);
// Clear any pending flash timer on unmount so its callback never runs
// against a detached anchor node.
useEffect(() => {
return () => {
if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
};
}, []);
requestAnimationFrame(() => {
const container = accordionContainerRef.current;
if (!container) return;
const anchor = container.querySelector(
`[data-domain-anchor="${CSS.escape(domainName)}"]`,
);
if (!(anchor instanceof HTMLElement)) return;
anchor.scrollIntoView({ behavior: "smooth", block: "start" });
anchor.dataset.flash = "1";
if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
flashTimeoutRef.current = setTimeout(() => {
delete anchor.dataset.flash;
flashTimeoutRef.current = null;
}, 1200);
});
},
[attributes.framework],
);
const handleDomainSelect = (domainName: string) => {
// Setting the forced key expands the target section. Selecting a domain
// is a user action, so the scroll + flash run here (not in a reactive
// effect): a single rAF lets the expansion commit before we locate the
// anchor and bring it into view.
setForcedExpandedSectionKey(`${attributes.framework}-${domainName}`);
requestAnimationFrame(() => {
const container = accordionContainerRef.current;
if (!container) return;
const anchor = container.querySelector(
`[data-domain-anchor="${CSS.escape(domainName)}"]`,
);
if (!(anchor instanceof HTMLElement)) return;
anchor.scrollIntoView({ behavior: "smooth", block: "start" });
anchor.dataset.flash = "1";
if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
flashTimeoutRef.current = setTimeout(() => {
delete anchor.dataset.flash;
flashTimeoutRef.current = null;
}, 1200);
});
};
return (
<div className="flex flex-col gap-6">
@@ -1,5 +1,3 @@
"use client";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import type {
DomainProviderStatus,
@@ -15,7 +13,7 @@ const HEATMAP_CELL_BY_STATUS: Record<DomainProviderStatus, string> = {
NO_ROW: "bg-default-200 border-border-neutral-secondary opacity-40",
};
const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => {
const getStripeClassForDominantStatus = (stats: DomainStats): string => {
if (stats.fail > 0) return "bg-bg-fail";
if (stats.pass > 0) return "bg-bg-pass";
if (stats.manual > 0) return "bg-bg-warning";
@@ -66,7 +64,7 @@ export const CrossProviderDomainTitle = ({
<div
className={cn(
"w-1 shrink-0 rounded-sm",
STRIPE_CLASS_BY_DOMINANT(stats),
getStripeClassForDominantStatus(stats),
)}
aria-hidden="true"
/>
@@ -1,7 +1,7 @@
"use client";
import { Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
@@ -99,7 +99,7 @@ export const CrossProviderExplorerCard = ({
// content. The unfiltered ``insights`` continue to feed the heatmap
// matrix per section so global counts stay stable while the user
// narrows their search.
const filteredAttributes = useMemo(() => {
const filteredAttributes = (() => {
const lowerTerm = searchTerm.trim().toLowerCase();
if (lowerTerm === "" && statusFilters.length === 0) {
return attributes;
@@ -113,7 +113,7 @@ export const CrossProviderExplorerCard = ({
return haystack.includes(lowerTerm);
});
return { ...attributes, requirements: filteredRequirements };
}, [attributes, searchTerm, statusFilters]);
})();
const matchCount = filteredAttributes.requirements.length;
const totalCount = attributes.requirements.length;
@@ -123,7 +123,7 @@ export const CrossProviderExplorerCard = ({
// run this against the *filtered* attribute set so the list shrinks
// as the user types — sections with no surviving requirements
// disappear entirely instead of expanding to an empty body.
const { sections, allSectionKeys, statsByName } = useMemo(() => {
const { sections, allSectionKeys, statsByName } = (() => {
const mapper = getComplianceMapper(filteredAttributes.framework);
const { attributesData, requirementsData } =
crossProviderToMapperInput(filteredAttributes);
@@ -150,43 +150,39 @@ export const CrossProviderExplorerCard = ({
allSectionKeys: allSections.map((s) => s.key),
statsByName: stats,
};
}, [filteredAttributes, insights]);
})();
const expandedKeys = useMemo(() => {
const expandedKeys = (() => {
if (allSectionsOpen) return allSectionKeys;
const merged = new Set(openSectionKeys);
if (forcedExpandedSectionKey) merged.add(forcedExpandedSectionKey);
return Array.from(merged);
}, [
allSectionsOpen,
allSectionKeys,
openSectionKeys,
forcedExpandedSectionKey,
]);
})();
const handleAccordionChange = useCallback((next: string[]) => {
const handleAccordionChange = (next: string[]) => {
// Manually expanding/collapsing exits "all open" mode so the
// user's last interaction is the source of truth.
setAllSectionsOpen(false);
setOpenSectionKeys(next);
}, []);
};
const handleToggleAll = useCallback(() => {
setAllSectionsOpen((prev) => {
const next = !prev;
setOpenSectionKeys(next ? allSectionKeys : []);
return next;
});
}, [allSectionKeys]);
const handleToggleAll = () => {
// Compute the next open/closed value once and drive both setters
// directly — nesting a ``setState`` inside another updater makes it
// impure (React Strict Mode double-invokes updaters to detect this).
const next = !allSectionsOpen;
setAllSectionsOpen(next);
setOpenSectionKeys(next ? allSectionKeys : []);
};
const handleResetFilters = useCallback(() => {
const handleResetFilters = () => {
setSearchTerm("");
// Cycling the controlled-input key flushes ``DataTableSearch``'s
// internal debounce so a stale keystroke doesn't fire on top of
// the just-cleared term.
setSearchKey((k) => k + 1);
setStatusFilters([]);
}, []);
};
return (
<Card variant="base" padding="md">
@@ -1,26 +1,61 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
import { CrossProviderHeader } from "./cross-provider-header";
vi.mock("next/image", () => ({
default: ({ alt }: { alt?: string }) => <span aria-label={alt} />,
}));
// The three data panes have their own tests; stub them so this suite renders
// only the header's metadata strip + Prowler Hub link wiring.
vi.mock("./provider-coverage-panel", () => ({
ProviderCoveragePanel: () => <div />,
}));
vi.mock("./score-donut", () => ({ ScoreDonut: () => <div /> }));
vi.mock("./top-failing-domains-panel", () => ({
TopFailingDomainsPanel: () => <div />,
}));
const insights = {} as CrossProviderInsights;
const baseProps = {
framework: "CSA-CCM",
name: "CSA Cloud Controls Matrix",
version: "4.0",
description: "A universal framework",
complianceId: "csa_ccm_4.0",
insights,
};
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("renders the framework name and version", () => {
render(<CrossProviderHeader {...baseProps} />);
it("accepts a complianceId prop to build the Prowler Hub link", () => {
expect(source).toContain("complianceId");
expect(source).toContain("getProwlerHubComplianceUrl(complianceId)");
expect(screen.getByText("CSA Cloud Controls Matrix")).toBeInTheDocument();
expect(screen.getByText("v4.0")).toBeInTheDocument();
});
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"/,
it("links to the framework's Prowler Hub page", () => {
render(<CrossProviderHeader {...baseProps} />);
const hubLink = screen.getByRole("link", { name: /prowler hub/i });
expect(hubLink).toHaveAttribute(
"href",
getProwlerHubComplianceUrl(baseProps.complianceId),
);
});
it("opens the Prowler Hub link in a new, referrer-stripped tab", () => {
render(<CrossProviderHeader {...baseProps} />);
const hubLink = screen.getByRole("link", { name: /prowler hub/i });
expect(hubLink).toHaveAttribute("target", "_blank");
// ``noopener`` blocks the opened tab from reaching back via
// ``window.opener``; ``noreferrer`` strips the referrer header.
expect(hubLink).toHaveAttribute("rel", "noopener noreferrer");
});
});
@@ -13,6 +13,7 @@ import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
import { ProviderCoveragePanel } from "./provider-coverage-panel";
import { ScoreDonut } from "./score-donut";
import { TopFailingDomainsPanel } from "./top-failing-domains-panel";
import { formatTitle } from "./utils";
interface CrossProviderHeaderProps {
/** Universal framework key (``CSA-CCM``). Drives the icon lookup. */
@@ -31,8 +32,6 @@ interface CrossProviderHeaderProps {
onDomainSelect?: (domainName: string) => void;
}
const formatTitle = (title: string) => title.split("-").join(" ");
/**
* Three-pane operations header for the cross-provider compliance view.
*
@@ -136,6 +136,11 @@ export const CrossProviderPdfWatcher = () => {
task.data?.attributes?.result?.error ||
`The report generation task did not complete (state: ${taskState ?? "unknown"}).`,
});
} catch (_error) {
// ``getTask`` is contracted to return a normalized ``{data}``/
// ``{error}`` shape rather than throw, so this is defense-in-depth:
// swallow any unexpected throw (from getTask/markCompleted/
// markFailed/toast) so it never becomes an unhandled rejection.
} finally {
state.busy = false;
}
@@ -2,7 +2,7 @@
import { DownloadIcon, FileDown, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
import { useRef, useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import {
@@ -208,70 +208,59 @@ export const GeneratePdfButton = ({
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 handleGenerate = 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 });
},
[
const trimmedName = chosenName.trim();
const result = await generateCrossProviderCompliancePdf({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
isGenerating,
trackGeneration,
],
);
// 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 });
};
// Opening the dialog seeds the input with a descriptive default the user
// can accept or overwrite.
const openNameDialog = useCallback(() => {
const openNameDialog = () => {
setReportName(buildDefaultReportName(frameworkLabel));
setNameDialogOpen(true);
}, [frameworkLabel]);
};
const confirmGenerate = useCallback(() => {
const confirmGenerate = () => {
setNameDialogOpen(false);
void handleGenerate(reportName);
}, [handleGenerate, reportName]);
};
const handleDownload = useCallback(async () => {
const handleDownload = async () => {
if (!hasAvailableReport || isDownloading || isGenerating) return;
setIsDownloading(true);
toast({
@@ -311,13 +300,7 @@ export const GeneratePdfButton = ({
} 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",
@@ -3,6 +3,7 @@
import { AlertTriangle, ChevronRight } from "lucide-react";
import type { DomainStats } from "@/lib/compliance/cross-provider-insights";
import { cn } from "@/lib/utils";
interface TopFailingDomainsPanelProps {
domains: DomainStats[];
@@ -49,11 +50,11 @@ export const TopFailingDomainsPanel = ({
<Tag
type={onSelect ? "button" : undefined}
onClick={onSelect ? () => onSelect(domain.name) : undefined}
className={`border-border-neutral-secondary flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors ${
onSelect
? "hover:border-bg-fail/40 hover:bg-bg-fail/5 cursor-pointer"
: ""
}`}
className={cn(
"border-border-neutral-secondary flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
onSelect &&
"hover:border-bg-fail/40 hover:bg-bg-fail/5 cursor-pointer",
)}
>
<AlertTriangle
className="text-bg-fail size-4 shrink-0"
@@ -0,0 +1,6 @@
/**
* Turn a hyphenated framework key (e.g. ``CSA-CCM``) into a display title
* (``CSA CCM``). Shared by the cross-provider card and header so both render
* framework names identically.
*/
export const formatTitle = (title: string) => title.split("-").join(" ");
+7 -4
View File
@@ -18,7 +18,11 @@ vi.mock(
() => ({ ComplianceAccordionTitle: () => null }),
);
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import type {
AttributesData,
CrossProviderComplianceOverviewAttributes,
RequirementsData,
} from "@/types/compliance";
import { mapComplianceData, toAccordionItems } from "./cis-controls";
import { crossProviderToMapperInput } from "./cross-provider-adapter";
@@ -128,9 +132,8 @@ describe("cis-controls mapper — cross-provider augmentation", () => {
};
const frameworks = mapComplianceData(
perScanAttributesData as any,
perScanRequirementsData as any,
perScanAttributesData as unknown as AttributesData,
perScanRequirementsData as unknown as RequirementsData,
);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
+7 -4
View File
@@ -18,7 +18,11 @@ vi.mock(
() => ({ ComplianceAccordionTitle: () => null }),
);
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import type {
AttributesData,
CrossProviderComplianceOverviewAttributes,
RequirementsData,
} from "@/types/compliance";
import { crossProviderToMapperInput } from "./cross-provider-adapter";
import { mapComplianceData, toAccordionItems } from "./dora";
@@ -125,9 +129,8 @@ describe("dora mapper — cross-provider augmentation", () => {
};
const frameworks = mapComplianceData(
perScanAttributesData as any,
perScanRequirementsData as any,
perScanAttributesData as unknown as AttributesData,
perScanRequirementsData as unknown as RequirementsData,
);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
+23 -4
View File
@@ -1,5 +1,13 @@
import { create } from "zustand";
export const PDF_GENERATION_STATUS = {
RUNNING: "running",
COMPLETED: "completed",
FAILED: "failed",
} as const;
export type PdfGenerationStatus =
(typeof PDF_GENERATION_STATUS)[keyof typeof PDF_GENERATION_STATUS];
/** A single in-flight (or just-finished) cross-provider PDF generation. */
export interface CrossProviderPdfGeneration {
/** The generation task id returned by the server action. */
@@ -18,7 +26,7 @@ export interface CrossProviderPdfGeneration {
* generated it — even if they navigated away while it was rendering.
*/
reportUrl: string;
status: "running" | "completed" | "failed";
status: PdfGenerationStatus;
}
interface CrossProviderPdfState {
@@ -63,16 +71,27 @@ export const useCrossProviderPdfStore = create<CrossProviderPdfState>(
set((state) => ({
generations: {
...state.generations,
[entry.taskId]: { ...entry, status: "running" },
[entry.taskId]: {
...entry,
status: PDF_GENERATION_STATUS.RUNNING,
},
},
})),
markCompleted: (taskId) =>
set((state) => ({
generations: setStatus(state.generations, taskId, "completed"),
generations: setStatus(
state.generations,
taskId,
PDF_GENERATION_STATUS.COMPLETED,
),
})),
markFailed: (taskId) =>
set((state) => ({
generations: setStatus(state.generations, taskId, "failed"),
generations: setStatus(
state.generations,
taskId,
PDF_GENERATION_STATUS.FAILED,
),
})),
removeGeneration: (taskId) =>
set((state) => {
+7 -1
View File
@@ -526,7 +526,13 @@ export interface CategoryData {
export const CROSS_PROVIDER_COMPLIANCE_TYPE =
"cross-provider-compliance-overviews" as const;
export type CrossProviderRequirementStatus = "PASS" | "FAIL" | "MANUAL";
export const CROSS_PROVIDER_REQUIREMENT_STATUS = {
PASS: "PASS",
FAIL: "FAIL",
MANUAL: "MANUAL",
} as const;
export type CrossProviderRequirementStatus =
(typeof CROSS_PROVIDER_REQUIREMENT_STATUS)[keyof typeof CROSS_PROVIDER_REQUIREMENT_STATUS];
export interface CrossProviderRequirementData {
id: string;