diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 843668d9cb..c6e3b9492d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -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 || diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 92195c0c1e..a1371cfd66 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -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 ( @@ -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), diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 5228120b11..3a781de7d1 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -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 ( diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 635429aebe..93c43d5c9b 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -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 = {}; const pass: Record = {}; const fail: Record = {}; @@ -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 (
-

Per-Provider Breakdown

-
+

+ Per-Provider Breakdown +

+
- + - - - - - + + + + + @@ -199,29 +203,35 @@ export const ClientAccordionContent = ({ return ( - - - - - @@ -379,6 +392,26 @@ export const ClientAccordionContent = ({ <>

Findings

+ {isPartial && ( + + + + + Some providers' findings could not be loaded, so this + view may be incomplete. + + + + + )} + {label} diff --git a/ui/components/compliance/compliance-accordion/use-requirement-findings.ts b/ui/components/compliance/compliance-accordion/use-requirement-findings.ts index c7b87b63bb..61b25a73fc 100644 --- a/ui/components/compliance/compliance-accordion/use-requirement-findings.ts +++ b/ui/components/compliance/compliance-accordion/use-requirement-findings.ts @@ -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(null); const [expandedFindings, setExpandedFindings] = useState([]); const [error, setError] = useState(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, }; diff --git a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx index c348d52b10..0d3e7a7765 100644 --- a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx @@ -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 }) => , +})); + +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(); + + // 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(); + + 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(); + 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(); + + // 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* { + render(); + + 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(); - 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(); }); }); diff --git a/ui/components/compliance/cross-provider/cross-provider-card.tsx b/ui/components/compliance/cross-provider/cross-provider-card.tsx index c1ec27a349..303ef258d7 100644 --- a/ui/components/compliance/cross-provider/cross-provider-card.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-card.tsx @@ -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 = ({ +export const CrossProviderCard = ({ complianceId, title, version, @@ -95,7 +95,7 @@ export const CrossProviderCard: React.FC = ({ totalRequirements, contributingProviders, compatibleProviders, -}) => { +}: CrossProviderCardProps) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -141,6 +141,14 @@ export const CrossProviderCard: React.FC = ({ 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(); + } + }} >
diff --git a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx index bec5e48837..03afa705a2 100644 --- a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx @@ -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(null); const flashTimeoutRef = useRef | 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 (
diff --git a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx index 0c431edfb5..3754611361 100644 --- a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx @@ -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 = { 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 = ({
ProviderStatusFindingsPass / FailScan IDProviderStatusFindings + Pass / Fail + Scans
-
- {label} - {accountCount > 1 && ( - - {accountCount} accounts - - )} +
+
+ +
+ {label} + {accountCount > 1 && ( + + {accountCount} accounts + + )} +
+ + {findingsLoaded ? (findingsCount ?? 0) : "—"} + {findingsLoaded ? ( - + {passCount} {" / "} @@ -232,18 +242,21 @@ export const ClientAccordionContent = ({ — )} + {accountCount === 0 ? ( - "—" + — ) : ( -
    +
    {scanIdsForProvider.map((sid) => ( -
  • {sid}
  • + + {sid} + ))} -
+ )}