diff --git a/ui/app/(prowler)/compliance/_actions/cross-account.test.ts b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts index e0b8957467..4775166110 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-account.test.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts @@ -28,7 +28,12 @@ vi.mock("@sentry/nextjs", () => ({ captureException: captureExceptionMock, })); -import { getCrossAccountComplianceOverview } from "./cross-account"; +import { + generateCrossAccountPdf, + getCrossAccountComplianceOverview, + getCrossAccountPdfBinary, + getLatestCrossAccountPdf, +} from "./cross-account"; const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { @@ -42,6 +47,15 @@ const lastFetchUrl = () => { return new URL(String(call[0])); }; +const fetchCallAt = (index: number) => { + const call = fetchMock.mock.calls[index]; + if (!call) throw new Error(`fetch call ${index} was not found`); + return { + init: call[1] as RequestInit, + url: new URL(String(call[0])), + }; +}; + beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); @@ -81,6 +95,73 @@ describe("cross-account compliance actions", () => { expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1"); }); + it("routes PDF operations through the cross-account endpoints", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ) + .mockResolvedValueOnce( + new Response(Buffer.from("pdf-bytes"), { + headers: { + "Content-Disposition": 'attachment; filename="report.pdf"', + "Content-Type": "application/pdf", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + data: { + id: "task-2", + attributes: { result: { filename: "latest.pdf" } }, + }, + }), + ); + + await generateCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { scanIds: ["scan-1"] }, + reportName: "report.pdf", + }); + await getCrossAccountPdfBinary("task-1"); + await getLatestCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { providerIds: "provider-1" }, + }); + + const generation = fetchCallAt(0); + expect(generation.init.method).toBe("POST"); + expect(generation.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf", + ); + expect(generation.url.searchParams.get("filter[compliance_id]")).toBe( + "cis_2.0_aws", + ); + expect(generation.url.searchParams.get("filter[provider_type]")).toBe( + "aws", + ); + expect(generation.url.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(generation.url.searchParams.get("report_name")).toBe("report.pdf"); + + const binary = fetchCallAt(1); + expect(binary.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/task-1", + ); + + const latest = fetchCallAt(2); + expect(latest.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/latest", + ); + expect(latest.url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1", + ); + + expect([generation, binary, latest].every(({ init }) => init.signal)).toBe( + true, + ); + }); + it("aborts a stalled request and reports the network failure", async () => { vi.useFakeTimers(); let requestSignal: AbortSignal | undefined; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx index b245e79762..0a8de5abb0 100644 --- a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx @@ -77,7 +77,7 @@ export const AggregatedComplianceDetail = ({ ); diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx index ac30a01f53..bc1f83ef3e 100644 --- a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx +++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx @@ -40,6 +40,7 @@ describe("ProviderCoverageCard", () => { expect(screen.getByTestId("coverage-row-aws")).toBeInTheDocument(); expect(screen.queryByTestId("coverage-row-gcp")).not.toBeInTheDocument(); expect(screen.queryByText("No completed scan")).not.toBeInTheDocument(); + expect(screen.getByText("8/10 · 1 manual")).toBeInTheDocument(); }); it("shows an empty state when no provider has a scan", () => { diff --git a/ui/app/(prowler)/compliance/_components/requirement-account-chips.test.tsx b/ui/app/(prowler)/compliance/_components/requirement-account-chips.test.tsx index ea89f1bc17..182176ad07 100644 --- a/ui/app/(prowler)/compliance/_components/requirement-account-chips.test.tsx +++ b/ui/app/(prowler)/compliance/_components/requirement-account-chips.test.tsx @@ -44,7 +44,7 @@ describe("RequirementAccountChips", () => { render(); const summary = screen.getByRole("button", { - name: "Show status breakdown for 13 providers", + name: "Show status breakdown for 13 accounts", }); expect(summary).toHaveTextContent("Fail×2"); expect(summary).toHaveTextContent("Manual×1"); diff --git a/ui/app/(prowler)/compliance/_components/requirement-account-chips.tsx b/ui/app/(prowler)/compliance/_components/requirement-account-chips.tsx index 6db7f1ae90..0bfc14cf1e 100644 --- a/ui/app/(prowler)/compliance/_components/requirement-account-chips.tsx +++ b/ui/app/(prowler)/compliance/_components/requirement-account-chips.tsx @@ -40,6 +40,7 @@ export const RequirementAccountChips = ({ if (entries.length > MAX_INLINE_ACCOUNT_CHIPS) { return ( ({ key: account.id, label: accountDisplayLabel(account), diff --git a/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx b/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx index 97fb349430..257d4a9ce3 100644 --- a/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx +++ b/ui/app/(prowler)/compliance/_components/requirement-status-summary.tsx @@ -39,8 +39,10 @@ const SCROLLABLE_BREAKDOWN_MIN_ROWS = 13; */ export const RequirementStatusSummary = ({ entries, + entityLabel = "providers", }: { entries: RequirementStatusEntry[]; + entityLabel?: "accounts" | "providers"; }) => { const counts = STATUS_ORDER.map((status) => ({ status, @@ -74,7 +76,7 @@ export const RequirementStatusSummary = ({ type="button" variant="bare" size="link-xs" - aria-label={`Show status breakdown for ${entries.length} providers`} + aria-label={`Show status breakdown for ${entries.length} ${entityLabel}`} data-testid="requirement-status-summary" > {counts.map(({ status, count }) => ( diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/aggregated-compliance-detail.test.ts b/ui/app/(prowler)/compliance/_lib/__tests__/aggregated-compliance-detail.test.ts new file mode 100644 index 0000000000..0db3bf6855 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/__tests__/aggregated-compliance-detail.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import type { Framework } from "@/types/compliance"; + +import { getAggregatedInitialExpandedKeys } from "../aggregated-compliance-detail"; + +describe("getAggregatedInitialExpandedKeys", () => { + it("returns the ancestor path for a nested section deep link", () => { + const data = [ + { name: "Operational" }, + { name: "Organizational" }, + ] as Framework[]; + const accordionItems: AccordionItemProps[] = [ + { + key: "Operational", + title: null, + content: null, + items: [ + { + key: "Operational-Access control", + title: null, + content: null, + }, + ], + }, + ]; + + expect( + getAggregatedInitialExpandedKeys(data, accordionItems, "Access control"), + ).toEqual(["Operational", "Operational-Access control"]); + }); +}); diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts index 2fda5da801..0c36b5e840 100644 --- a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-detail.ts @@ -23,6 +23,21 @@ export const getAggregatedInitialExpandedKeys = ( const candidates = new Set( data.map((framework) => `${framework.name}-${targetSection}`), ); - const match = accordionItems.find((item) => candidates.has(item.key)); - return match ? [match.key] : []; + + const findExpandedPath = ( + items: AccordionItemProps[], + ancestors: string[] = [], + ): string[] | undefined => { + for (const item of items) { + const path = [...ancestors, item.key]; + if (candidates.has(item.key)) return path; + + const nestedMatch = findExpandedPath(item.items ?? [], path); + if (nestedMatch) return nestedMatch; + } + + return undefined; + }; + + return findExpandedPath(accordionItems) ?? []; };