diff --git a/ui/actions/compliance-watchlist/compliance-watchlist.test.ts b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts index a2b3f57c8c..831ba16e3b 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.test.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts @@ -94,6 +94,7 @@ describe("getComplianceCatalog", () => { eligibleProviderTypes: [], }, unavailable: true, + incomplete: false, }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -177,6 +178,7 @@ describe("getComplianceCatalog", () => { eligibleProviderTypes: [], }, unavailable: true, + incomplete: false, }); }); @@ -199,7 +201,7 @@ describe("getComplianceCatalog", () => { expect(catalog.entries).toEqual([]); }); - it("flags the catalog as unavailable when a single page fails", async () => { + it("keeps the loaded pages and flags the catalog as incomplete when a single page fails", async () => { // Given - one of three catalog pages times out fetchMock .mockResolvedValueOnce( @@ -218,7 +220,8 @@ describe("getComplianceCatalog", () => { "cis_1.4_aws", "iso27001_aws", ]); - expect(catalog.unavailable).toBe(true); + expect(catalog.unavailable).toBe(false); + expect(catalog.incomplete).toBe(true); }); it("bounds how many pages it requests at once", async () => { diff --git a/ui/actions/compliance-watchlist/compliance-watchlist.ts b/ui/actions/compliance-watchlist/compliance-watchlist.ts index 0707a5d666..c63e1dcb27 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.ts @@ -50,6 +50,7 @@ const EMPTY_CATALOG: ComplianceCatalog = { const UNAVAILABLE_CATALOG: ComplianceCatalogLoad = { ...EMPTY_CATALOG, unavailable: true, + incomplete: false, }; const GENERIC_ERROR = "Could not update the compliance watchlist."; @@ -140,7 +141,9 @@ export const getComplianceCatalog = async ( Math.max(1, firstPage.pageCount), MAX_CATALOG_PAGES, ); - if (pageCount <= 1) return { ...firstPage.catalog, unavailable: false }; + if (pageCount <= 1) { + return { ...firstPage.catalog, unavailable: false, incomplete: false }; + } const rest: ComplianceCatalog[] = []; let incomplete = firstPage.pageCount > MAX_CATALOG_PAGES; @@ -157,7 +160,8 @@ export const getComplianceCatalog = async ( return { ...mergeCatalogPages([firstPage.catalog, ...rest]), - unavailable: incomplete, + unavailable: false, + incomplete, }; } catch (error) { console.error("Error fetching compliance catalog:", error); diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts index 9f0cfa5d87..b8092931a9 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts @@ -148,6 +148,24 @@ describe("getCrossProviderComplianceOverview", () => { }); }); + it.each([404, 500])( + "returns a load error, not an action error, for a %i response", + async (status) => { + fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, status)); + handleApiResponseMock.mockResolvedValue({ error: "Not found.", status }); + + const result = await getCrossProviderComplianceOverview({ + complianceId: "nato_cloud_directive_rev1", + }); + + expect(result).toEqual({ + status: "load-error", + message: + "Could not load cross-provider compliance data. Try again later.", + }); + }, + ); + it("returns a load error when the network call throws", async () => { fetchMock.mockRejectedValue(new Error("boom")); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx index 6e4a8532c8..059de92c0e 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -123,6 +123,7 @@ describe("CrossAccountOverviewSection", () => { eligibleProviderTypes: [], canManage: false, unavailable: false, + incomplete: false, }); }); @@ -334,12 +335,14 @@ describe("CrossAccountOverviewSection watchlist", () => { entries: ReturnType[], canManage = true, unavailable = false, + incomplete = false, ) => vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ entries, eligibleProviderTypes: ["aws"], canManage, unavailable, + incomplete, }); it("keeps the provider-type grouping when the filter is on", async () => { @@ -384,6 +387,19 @@ describe("CrossAccountOverviewSection watchlist", () => { expect(container).toBeEmptyDOMElement(); }); + it("renders nothing when the catalog is missing pages", async () => { + withWatchlist( + [catalogEntry("cis_2.0_aws", "aws", false)], + true, + false, + true, + ); + + const { container } = await renderSection(); + + expect(container).toBeEmptyDOMElement(); + }); + it("ignores the filter without a catalog, so OSS never blanks out", async () => { useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true }); withWatchlist([], false); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx index eb2ace5a99..f3ee9657a1 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx @@ -89,8 +89,9 @@ export const CrossAccountOverviewSection = async ({ // Universal frameworks belong to "Across providers" above. Without the // catalog we cannot tell them apart, and that section already reports it. + // A partial catalog could list a universal framework here as well. const catalog = await loadCrossProviderFrameworks(); - if (catalog.unavailable) return null; + if (catalog.unavailable || catalog.incomplete) return null; const universalIds = new Set( catalog.frameworks.map((entry) => entry.complianceId), ); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx index de6a2c87b8..258c785125 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx @@ -100,12 +100,14 @@ const withCatalog = ( eligibleProviderTypes: string[] = ["aws", "azure"], canManage = true, unavailable = false, + incomplete = false, ) => vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ entries, eligibleProviderTypes, canManage, unavailable, + incomplete, }); const successResult = (complianceId: string): CrossProviderOverviewResult => ({ @@ -224,6 +226,28 @@ describe("CrossProviderOverview", () => { ).not.toBeInTheDocument(); }); + it("renders the frameworks it could list when a catalog page failed", async () => { + // Given: one catalog page timed out, the rest were read + withCatalog(catalogEntries(), ["aws", "azure"], true, false, true); + vi.mocked(getCrossProviderComplianceOverview).mockImplementation( + async ({ complianceId }) => successResult(complianceId), + ); + + // When + await renderOverview(); + + // Then + expect(screen.getAllByTestId("framework-card")).toHaveLength( + UNIVERSAL_FRAMEWORKS.length, + ); + expect( + screen.getByText(/Some compliance frameworks could not be listed/), + ).toBeInTheDocument(); + expect( + screen.queryByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE), + ).not.toBeInTheDocument(); + }); + it("replaces the tab with the error alert when every framework fails to load", async () => { // Given vi.mocked(getCrossProviderComplianceOverview).mockResolvedValue( diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx index 69b7150de3..826a9f156d 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -214,6 +214,16 @@ export const CrossProviderOverview = async ({ providerGroups={providerGroups} /> + {catalog.incomplete && ( + + + + Some compliance frameworks could not be listed. Showing the ones + that loaded — try again later. + + + )} + {loadErrors.length > 0 && ( @@ -226,6 +236,7 @@ export const CrossProviderOverview = async ({ )} {loadErrors.length === 0 && + !catalog.incomplete && summaries.every((summary) => summary.totalRequirements === 0) && ( diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts index 4df0c382c2..747d93fb88 100644 --- a/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts +++ b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts @@ -23,12 +23,17 @@ const universalEntry = ( ...overrides, }); -const withCatalog = (entries: ComplianceCatalogEntry[], unavailable = false) => +const withCatalog = ( + entries: ComplianceCatalogEntry[], + unavailable = false, + incomplete = false, +) => vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ entries, eligibleProviderTypes: ["aws", "azure", "gcp"], canManage: true, unavailable, + incomplete, }); describe("loadCrossProviderFrameworks", () => { @@ -148,6 +153,7 @@ describe("loadCrossProviderFrameworks", () => { expect(await loadCrossProviderFrameworks()).toEqual({ frameworks: [], unavailable: false, + incomplete: false, }); }); @@ -157,6 +163,7 @@ describe("loadCrossProviderFrameworks", () => { expect(await loadCrossProviderFrameworks()).toEqual({ frameworks: [], unavailable: true, + incomplete: false, }); }); }); diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts index 6d6004e5e5..9b3214ecb1 100644 --- a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts @@ -6,7 +6,11 @@ import { getAuthHeaders, getErrorMessage, } from "@/lib"; -import { hasActionError, type ActionErrorResult } from "@/lib/action-errors"; +import { + type ActionErrorResult, + hasActionError, + isActionErrorStatus, +} from "@/lib/action-errors"; import { isReportDownloadLocked, REPORT_DOWNLOAD_LOCKED_ERROR, @@ -152,9 +156,17 @@ export const getAggregatedComplianceOverview = async ( const responseData = await handleApiResponse(response); if (hasActionError(responseData)) { + // Only 402/403 gate the whole feature; any other failure is this + // framework alone and must not blank its siblings. + if (isActionErrorStatus(responseData.status)) { + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, + result: responseData, + }; + } return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, - result: responseData, + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, }; } diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts b/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts index 398d758677..f444dfa5cb 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts @@ -7,6 +7,8 @@ export interface CrossProviderCatalog { frameworks: CrossProviderFrameworkEntry[]; /** Empty for lack of an answer, not for lack of frameworks. */ unavailable: boolean; + /** Some catalog pages failed: `frameworks` may miss entries. */ + incomplete: boolean; } /** @@ -16,7 +18,8 @@ export interface CrossProviderCatalog { */ export const loadCrossProviderFrameworks = async (): Promise => { - const { entries, unavailable } = await loadComplianceWatchlistContext(); + const { entries, unavailable, incomplete } = + await loadComplianceWatchlistContext(); const frameworks = entries .filter((entry) => entry.scope === WATCHLIST_SCOPE.UNIVERSAL) @@ -31,5 +34,5 @@ export const loadCrossProviderFrameworks = .filter((entry) => entry.title.trim().length > 0) .sort((a, b) => a.title.localeCompare(b.title)); - return { frameworks, unavailable }; + return { frameworks, unavailable, incomplete }; }; diff --git a/ui/app/(prowler)/compliance/_lib/watchlist-context.ts b/ui/app/(prowler)/compliance/_lib/watchlist-context.ts index 686e6b9926..74c71de54b 100644 --- a/ui/app/(prowler)/compliance/_lib/watchlist-context.ts +++ b/ui/app/(prowler)/compliance/_lib/watchlist-context.ts @@ -11,6 +11,8 @@ export interface ComplianceWatchlistContext { canManage: boolean; /** Could not be read — not the same as legitimately empty. */ unavailable: boolean; + /** Read, but some catalog pages failed: `entries` is partial. */ + incomplete: boolean; } export const EMPTY_WATCHLIST_CONTEXT: ComplianceWatchlistContext = { @@ -18,6 +20,7 @@ export const EMPTY_WATCHLIST_CONTEXT: ComplianceWatchlistContext = { eligibleProviderTypes: [], canManage: false, unavailable: false, + incomplete: false, }; // One cached catalog/session lookup feeds every compliance surface in a render. @@ -38,6 +41,7 @@ const loadContextForKey = cache( eligibleProviderTypes: catalog.meta.eligibleProviderTypes, canManage: Boolean(session?.user?.permissions?.manage_scans), unavailable: catalog.unavailable, + incomplete: catalog.incomplete, }; } catch (error) { console.error("Error loading the compliance watchlist context:", error); diff --git a/ui/changelog.d/compliance-partial-catalog.fixed.md b/ui/changelog.d/compliance-partial-catalog.fixed.md new file mode 100644 index 0000000000..3dbbca0c0f --- /dev/null +++ b/ui/changelog.d/compliance-partial-catalog.fixed.md @@ -0,0 +1 @@ +Compliance cross-provider tab no longer blanks out when a single catalog page or framework fails to load; it shows the frameworks that loaded with a warning instead diff --git a/ui/lib/action-errors.ts b/ui/lib/action-errors.ts index bea6dcd8df..96b56139b9 100644 --- a/ui/lib/action-errors.ts +++ b/ui/lib/action-errors.ts @@ -33,7 +33,7 @@ interface GetActionErrorMessageOptions { fallback?: string; } -const isActionErrorStatus = ( +export const isActionErrorStatus = ( status: number | undefined, ): status is ActionErrorStatus => status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED || diff --git a/ui/types/compliance-watchlist.ts b/ui/types/compliance-watchlist.ts index 4acd08e279..29ba927b76 100644 --- a/ui/types/compliance-watchlist.ts +++ b/ui/types/compliance-watchlist.ts @@ -54,6 +54,8 @@ export interface ComplianceCatalog { export interface ComplianceCatalogLoad extends ComplianceCatalog { /** No answer was obtained — not the same as a legitimately empty catalog. */ unavailable: boolean; + /** Some pages failed: `entries` holds only the pages that loaded. */ + incomplete: boolean; } export interface FindingComplianceFramework {