fix(ui): handle pending compliance overview responses (#12358)

This commit is contained in:
Alejandro Bailo
2026-08-06 12:00:30 +02:00
committed by GitHub
parent 76d7a2882c
commit f727f1bb50
9 changed files with 582 additions and 58 deletions
+8 -2
View File
@@ -2,6 +2,12 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import type {
ComplianceOverviewApiResponse,
ComplianceRequirementsApiResponse,
} from "./types";
export const getCompliancesOverview = async ({
scanId,
@@ -11,7 +17,7 @@ export const getCompliancesOverview = async ({
scanId?: string;
region?: string | string[];
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
} = {}): Promise<ApiResult<ComplianceOverviewApiResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/compliance-overviews`);
@@ -115,7 +121,7 @@ export const getComplianceRequirements = async ({
complianceId: string;
scanId: string;
region?: string | string[];
}) => {
}): Promise<ApiResult<ComplianceRequirementsApiResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
try {
+5
View File
@@ -1,6 +1,11 @@
export * from "./compliances";
export * from "./compliances.adapter";
export { COMPLIANCE_OVERVIEW_RESOURCE_TYPE } from "./types";
export type {
ComplianceOverviewApiResponse,
ComplianceOverviewTaskResource,
ComplianceOverviewTaskResponse,
ComplianceOverviewsResponse,
ComplianceRequirementsApiResponse,
EnrichedComplianceOverview,
} from "./types";
+27 -1
View File
@@ -1,6 +1,14 @@
import { StaticImageData } from "next/image";
import { ComplianceOverviewData } from "@/types/compliance";
import type {
ComplianceOverviewData,
RequirementsData,
} from "@/types/compliance";
import type { TaskAttributes } from "@/types/tasks";
export const COMPLIANCE_OVERVIEW_RESOURCE_TYPE = {
TASK: "tasks",
} as const;
/**
* Raw API response from /compliance-overviews endpoint
@@ -16,6 +24,24 @@ export interface ComplianceOverviewsResponse {
};
}
export interface ComplianceOverviewTaskResource {
id: string;
type: typeof COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK;
attributes?: TaskAttributes;
}
export interface ComplianceOverviewTaskResponse {
data: ComplianceOverviewTaskResource;
}
export type ComplianceOverviewApiResponse =
| ComplianceOverviewsResponse
| ComplianceOverviewTaskResponse;
export type ComplianceRequirementsApiResponse =
| RequirementsData
| ComplianceOverviewTaskResponse;
/**
* Enriched compliance overview with computed fields
*/
@@ -0,0 +1,237 @@
import { render, screen } from "@testing-library/react";
import {
Children,
isValidElement,
Suspense,
type ReactElement,
type ReactNode,
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ComplianceDetail from "./page";
const {
getComplianceAttributesMock,
getComplianceOverviewMetadataInfoMock,
getComplianceRequirementsMock,
getScanMock,
mapComplianceDataMock,
} = vi.hoisted(() => ({
getComplianceAttributesMock: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getComplianceRequirementsMock: vi.fn(),
getScanMock: vi.fn(),
mapComplianceDataMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
notFound: vi.fn(() => {
throw new Error("notFound");
}),
redirect: vi.fn(() => {
throw new Error("redirect");
}),
}));
vi.mock("@/actions/compliances", () => ({
COMPLIANCE_OVERVIEW_RESOURCE_TYPE: { TASK: "tasks" },
getComplianceAttributes: getComplianceAttributesMock,
getComplianceOverviewMetadataInfo: getComplianceOverviewMetadataInfoMock,
getComplianceRequirements: getComplianceRequirementsMock,
getCompliancesOverview: vi.fn(),
}));
vi.mock("@/actions/overview", () => ({
getThreatScore: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
getScan: getScanMock,
}));
vi.mock("@/components/compliance", () => ({
ClientAccordionWrapper: () => <div>Empty requirements</div>,
ComplianceDownloadContainer: () => null,
ComplianceHeader: () => null,
ComplianceWarming: () => null,
RequirementsStatusCard: ({
pass,
fail,
manual,
}: {
pass: number;
fail: number;
manual: number;
}) => (
<div>
Requirements: {pass} pass, {fail} fail, {manual} manual
</div>
),
RequirementsStatusCardSkeleton: () => null,
SkeletonAccordion: () => null,
ThreatScoreBreakdownCard: () => null,
ThreatScoreBreakdownCardSkeleton: () => null,
TopFailedSectionsCard: () => null,
TopFailedSectionsCardSkeleton: () => null,
}));
vi.mock("@/components/icons/compliance/IconCompliance", () => ({
getComplianceIcon: vi.fn(),
}));
vi.mock("@/components/lighthouse/context-contributor", () => ({
LighthouseContextContributor: () => null,
}));
vi.mock("@/components/shadcn/button/button", () => ({
Button: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/shadcn/card/card", () => ({
Card: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/lib/compliance/compliance-mapper", () => ({
getComplianceMapper: () => ({
getTopFailedSections: vi.fn(() => []),
mapComplianceData: mapComplianceDataMock,
toAccordionItems: vi.fn(() => []),
}),
}));
vi.mock("@/lib/compliance/compliance-report-types", () => ({
getReportTypeForCompliance: vi.fn(),
pickLatestCisPerProvider: vi.fn(() => new Set()),
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
vi.mock("../_components/cross-account-detail", () => ({
CrossAccountDetail: () => null,
}));
vi.mock("../_components/cross-provider-detail", () => ({
CrossProviderDetail: () => null,
}));
vi.mock("../_lib/cross-provider-frameworks", () => ({
resolveCrossProviderFramework: vi.fn(),
}));
vi.mock("../_lib/search-params-key", () => ({
buildSearchParamsKey: vi.fn(() => "search-params"),
}));
interface ContentLayoutTestProps {
children: ReactNode;
}
type AsyncServerComponent = (
props: Record<string, unknown>,
) => Promise<ReactNode>;
const renderPerScanContent = async () => {
const page = (await ComplianceDetail({
params: Promise.resolve({ compliancetitle: "ISO 27001" }),
searchParams: Promise.resolve({
complianceId: "iso27001_2022_aws",
scanId: "scan-1",
}),
})) as ReactElement<ContentLayoutTestProps>;
const suspense = Children.toArray(page.props.children).find(
(child) => isValidElement(child) && child.type === Suspense,
);
if (!isValidElement<{ children: ReactElement }>(suspense)) {
throw new Error("Expected the per-scan compliance Suspense boundary");
}
const content = suspense.props.children as ReactElement<
Record<string, unknown>,
AsyncServerComponent
>;
render(await content.type(content.props));
};
describe("Compliance detail task response", () => {
beforeEach(() => {
vi.clearAllMocks();
getComplianceOverviewMetadataInfoMock.mockResolvedValue({
data: { attributes: { regions: [] } },
});
getComplianceAttributesMock.mockResolvedValue({
data: [
{
id: "iso27001_2022_aws",
type: "compliance-overview-attributes",
attributes: {
compliance_name: "ISO 27001",
framework: "ISO27001",
},
},
],
});
getScanMock.mockResolvedValue(undefined);
mapComplianceDataMock.mockImplementation(
(_attributesData, requirementsData) => {
const requirements = requirementsData.data;
requirements.forEach(() => undefined);
return [];
},
);
});
it("renders an empty detail while requirements are being generated", async () => {
// Given - the requirements endpoint returned a JSON:API task resource
getComplianceRequirementsMock.mockResolvedValue({
data: {
id: "task-1",
type: "tasks",
attributes: { state: "executing" },
},
});
// When - the server-rendered detail handles the pending response
await renderPerScanContent();
// Then - the task never reaches the requirements array mapper
expect(
screen.getByText("Requirements: 0 pass, 0 fail, 0 manual"),
).toBeInTheDocument();
});
it("maps a completed requirements collection", async () => {
// Given - the requirements endpoint returned its normal collection
getComplianceRequirementsMock.mockResolvedValue({
data: [
{
id: "requirement-1",
type: "compliance-overview-requirements",
attributes: { status: "PASS" },
},
],
});
mapComplianceDataMock.mockReturnValue([
{
name: "ISO 27001",
pass: 1,
fail: 0,
manual: 0,
},
]);
// When - the server-rendered detail handles the completed response
await renderPerScanContent();
// Then - normal mapper output is still rendered
expect(
screen.getByText("Requirements: 1 pass, 0 fail, 0 manual"),
).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import {
COMPLIANCE_OVERVIEW_RESOURCE_TYPE,
getComplianceAttributes,
getComplianceOverviewMetadataInfo,
getComplianceRequirements,
@@ -397,9 +398,14 @@ const SSRComplianceContent = async ({
scanId,
region,
});
const type = requirementsData?.data?.[0]?.type;
const requirements = requirementsData?.data;
const type = Array.isArray(requirements) ? undefined : requirements?.type;
if (!scanId || type === "tasks") {
if (
!scanId ||
type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK ||
!Array.isArray(requirements)
) {
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
@@ -416,7 +422,7 @@ const SSRComplianceContent = async ({
const mapper = getComplianceMapper(framework);
const data = mapper.mapComplianceData(
attributesData,
requirementsData,
{ data: requirements },
filter,
);
// const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data);
@@ -7,6 +7,7 @@ import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
import type { ComplianceOverviewData } from "@/types/compliance";
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
@@ -88,6 +89,23 @@ const scansFor = (scans: Array<{ id: string; providerId: string }>) => ({
],
});
const complianceOverview = (
id: string,
framework: string,
version: string,
): ComplianceOverviewData => ({
id,
type: "compliance-overviews",
attributes: {
framework,
version,
requirements_passed: 0,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: 0,
},
});
const renderSection = async (
searchParams: Record<string, string | string[] | undefined> = {},
) => render(await CrossAccountOverviewSection({ searchParams }));
@@ -131,20 +149,15 @@ describe("CrossAccountOverviewSection", () => {
);
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "cis_2.0_aws",
attributes: { framework: "CIS", version: "2.0" },
},
complianceOverview("cis_2.0_aws", "CIS", "2.0"),
// Universal frameworks have their own cross-provider cards above.
{
id: "csa_ccm_4.0",
attributes: { framework: "CSA-CCM", version: "4.0" },
},
complianceOverview("csa_ccm_4.0", "CSA-CCM", "4.0"),
// ThreatScore is excluded, matching the per-scan grid.
{
id: "prowler_threatscore_aws",
attributes: { framework: "ProwlerThreatScore", version: "1.0" },
},
complianceOverview(
"prowler_threatscore_aws",
"ProwlerThreatScore",
"1.0",
),
],
});
@@ -216,12 +229,7 @@ describe("CrossAccountOverviewSection", () => {
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
);
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "cis_2.0_aws",
attributes: { framework: "CIS", version: "2.0" },
},
],
data: [complianceOverview("cis_2.0_aws", "CIS", "2.0")],
});
// When
@@ -259,12 +267,7 @@ describe("CrossAccountOverviewSection", () => {
return scansFor([]);
});
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{
id: "framework-1",
attributes: { framework: "Framework", version: "1.0" },
},
],
data: [complianceOverview("framework-1", "Framework", "1.0")],
});
// When
@@ -312,11 +315,8 @@ describe("CrossAccountOverviewSection watchlist", () => {
);
vi.mocked(getCompliancesOverview).mockResolvedValue({
data: [
{ id: "cis_2.0_aws", attributes: { framework: "CIS", version: "2.0" } },
{
id: "gdpr_aws",
attributes: { framework: "GDPR", version: "1.0" },
},
complianceOverview("cis_2.0_aws", "CIS", "2.0"),
complianceOverview("gdpr_aws", "GDPR", "1.0"),
],
});
});
+230 -1
View File
@@ -2,7 +2,132 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import {
Children,
isValidElement,
Suspense,
type ReactElement,
type ReactNode,
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Compliance from "./page";
const {
complianceOverviewGridSpy,
getComplianceOverviewMetadataInfoMock,
getCompliancesOverviewMock,
getScansMock,
getThreatScoreMock,
loadComplianceWatchlistContextMock,
} = vi.hoisted(() => ({
complianceOverviewGridSpy: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getCompliancesOverviewMock: vi.fn(),
getScansMock: vi.fn(),
getThreatScoreMock: vi.fn(),
loadComplianceWatchlistContextMock: vi.fn(),
}));
vi.mock("@/actions/compliances", () => ({
COMPLIANCE_OVERVIEW_RESOURCE_TYPE: { TASK: "tasks" },
getComplianceOverviewMetadataInfo: getComplianceOverviewMetadataInfoMock,
getCompliancesOverview: getCompliancesOverviewMock,
}));
vi.mock("@/actions/overview", () => ({
getThreatScore: getThreatScoreMock,
}));
vi.mock("@/actions/scans", () => ({
getScans: getScansMock,
getScansByState: vi.fn(),
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
vi.mock("./_lib/watchlist-context", () => ({
loadComplianceWatchlistContext: loadComplianceWatchlistContextMock,
}));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./_components/compliance-page-tabs", () => ({
CompliancePageTabs: ({ perScanContent }: { perScanContent: ReactNode }) => (
<>{perScanContent}</>
),
}));
vi.mock("./_components/cross-account-overview-section", () => ({
CrossAccountOverviewSection: () => <div>Cross-account overview</div>,
}));
vi.mock("./_components/cross-provider-overview", () => ({
CrossProviderOverview: () => <div>Cross-provider overview</div>,
}));
vi.mock("./_components/multiple-scans-skeleton", () => ({
CrossAccountOverviewSkeleton: () => <div>Cross-account loading</div>,
CrossProviderOverviewSkeleton: () => <div>Cross-provider loading</div>,
}));
vi.mock("@/components/compliance", () => ({
ComplianceSkeletonGrid: () => <div>Loading compliance data</div>,
NoScansAvailable: () => <div>No scans available</div>,
ThreatScoreBadge: () => <div>Threat score</div>,
}));
vi.mock("@/components/compliance/compliance-header/compliance-filters", () => ({
ComplianceFilters: () => <div>Compliance filters</div>,
}));
vi.mock("@/components/compliance/compliance-overview-grid", () => ({
ComplianceOverviewGrid: (props: { frameworks: Array<{ id: string }> }) => {
complianceOverviewGridSpy(props);
return <div>Compliance overview grid</div>;
},
}));
vi.mock("@/components/compliance/watchlist/watchlist-controls", () => ({
WatchlistControls: () => <div>Watchlist controls</div>,
}));
interface ComplianceTabsTestProps {
perScanContent: ReactElement<{ children: ReactNode }>;
}
interface ContentLayoutTestProps {
children: ReactElement<ComplianceTabsTestProps>;
}
type AsyncServerComponent = (
props: Record<string, unknown>,
) => Promise<ReactNode>;
const renderPerScanGrid = async () => {
const page = (await Compliance({
searchParams: Promise.resolve({ scanId: "scan-1" }),
})) as ReactElement<ContentLayoutTestProps>;
const perScanContent = page.props.children.props.perScanContent;
const suspense = Children.toArray(perScanContent.props.children).find(
(child) => isValidElement(child) && child.type === Suspense,
);
if (!isValidElement<{ children: ReactElement }>(suspense)) {
throw new Error("Expected the per-scan compliance Suspense boundary");
}
const grid = suspense.props.children as ReactElement<
Record<string, unknown>,
AsyncServerComponent
>;
render(await grid.type(grid.props));
};
describe("Compliance overview page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
@@ -19,3 +144,107 @@ describe("Compliance overview page", () => {
expect(source).toContain("<CrossAccountOverviewSkeleton />");
});
});
describe("Compliance overview task response", () => {
beforeEach(() => {
vi.clearAllMocks();
getScansMock.mockResolvedValue({
data: [
{
id: "scan-1",
attributes: {
name: "Production scan",
completed_at: "2026-08-05T17:00:00Z",
},
relationships: {
provider: { data: { id: "provider-1" } },
},
},
],
included: [
{
id: "provider-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production",
},
},
],
});
getComplianceOverviewMetadataInfoMock.mockResolvedValue({
data: { attributes: { regions: [] } },
});
getThreatScoreMock.mockResolvedValue({ data: [] });
loadComplianceWatchlistContextMock.mockResolvedValue({
entries: [],
canManage: false,
});
});
it("shows a pending state while compliance data is being generated", async () => {
// Given - API returned the JSON:API task resource from its HTTP 202 response
getCompliancesOverviewMock.mockResolvedValue({
data: {
id: "task-1",
type: "tasks",
attributes: { state: "executing" },
},
});
// When - the server-rendered compliance page handles the response
await renderPerScanGrid();
// Then - the request remains renderable instead of throwing on data.filter
expect(
await screen.findByText(
"Compliance data is still being generated. Please try again shortly.",
),
).toBeInTheDocument();
});
it("renders framework arrays after removing ThreatScore", async () => {
// Given - API returned its normal compliance overview collection
getCompliancesOverviewMock.mockResolvedValue({
data: [
{
id: "prowler_threatscore_aws",
type: "compliance-overviews",
attributes: { framework: "ProwlerThreatScore" },
},
{
id: "cis_1.5_aws",
type: "compliance-overviews",
attributes: { framework: "CIS", version: "1.5" },
},
],
});
// When - the server-rendered compliance grid handles the collection
await renderPerScanGrid();
// Then - normal rendering remains unchanged
expect(screen.getByText("Compliance overview grid")).toBeInTheDocument();
expect(complianceOverviewGridSpy).toHaveBeenCalledWith(
expect.objectContaining({
frameworks: [expect.objectContaining({ id: "cis_1.5_aws" })],
}),
);
});
it("shows the invalid scan message for a JSON:API error response", async () => {
// Given - handleApiResponse converted a client error to its error result
getCompliancesOverviewMock.mockResolvedValue({
error: "Invalid scan ID.",
errors: [{ detail: "Invalid scan ID." }],
status: 400,
});
// When - the server-rendered compliance grid handles the error
await renderPerScanGrid();
// Then - the intended error state is reachable
expect(screen.getByText("Provide a valid scan ID.")).toBeInTheDocument();
});
});
+36 -22
View File
@@ -2,6 +2,7 @@ import { Info } from "lucide-react";
import { Suspense } from "react";
import {
COMPLIANCE_OVERVIEW_RESOURCE_TYPE,
getComplianceOverviewMetadataInfo,
getCompliancesOverview,
} from "@/actions/compliances";
@@ -317,21 +318,37 @@ const SSRComplianceGrid = async ({
})
: { data: [], errors: [] };
const type = compliancesData?.data?.type;
const frameworks = compliancesData?.data
?.filter((compliance: ComplianceOverviewData) => {
return compliance.attributes.framework !== "ProwlerThreatScore";
})
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
a.attributes.framework.localeCompare(b.attributes.framework),
);
const complianceData = compliancesData?.data;
if (
!compliancesData ||
!compliancesData.data ||
compliancesData.data.length === 0 ||
type === "tasks"
compliancesData &&
"errors" in compliancesData &&
compliancesData.errors &&
compliancesData.errors.length > 0
) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>Provide a valid scan ID.</AlertDescription>
</Alert>
);
}
if (
!Array.isArray(complianceData) &&
complianceData?.type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK
) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
Compliance data is still being generated. Please try again shortly.
</AlertDescription>
</Alert>
);
}
if (!Array.isArray(complianceData) || complianceData.length === 0) {
return (
<Alert variant="info">
<Info className="size-4" />
@@ -343,20 +360,17 @@ const SSRComplianceGrid = async ({
);
}
if (compliancesData?.errors?.length > 0) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>Provide a valid scan ID.</AlertDescription>
</Alert>
const frameworks = complianceData
.filter((compliance: ComplianceOverviewData) => {
return compliance.attributes.framework !== "ProwlerThreatScore";
})
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
a.attributes.framework.localeCompare(b.attributes.framework),
);
}
// Backend only generates CIS PDFs for the latest version per provider.
const latestCisIds = pickLatestCisPerProvider(
compliancesData.data.map(
(compliance: ComplianceOverviewData) => compliance.id,
),
complianceData.map((compliance: ComplianceOverviewData) => compliance.id),
);
// The watchlist is keyed by `(compliance_id, provider_type)`, and on this
@@ -0,0 +1 @@
`/compliance` no longer fails while compliance overview data is still being generated