mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): handle pending compliance overview responses (#12358)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user