mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-15 16:04:18 +00:00
chore(coderabbitai): add missing changes
This commit is contained in:
21 files changed
+474
-238
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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' 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,
|
||||
|
||||
+5
-3
@@ -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(" ");
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user