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) ?? [];
};