mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 15:24:22 +00:00
fix(ui): keep compliance tab when one catalog page fails (#13183)
This commit is contained in:
1 parent
dcd3d5a0fb
commit
905c60fa85
14 files changed
+118
-12
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ describe("CrossAccountOverviewSection", () => {
|
||||
eligibleProviderTypes: [],
|
||||
canManage: false,
|
||||
unavailable: false,
|
||||
incomplete: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,12 +335,14 @@ describe("CrossAccountOverviewSection watchlist", () => {
|
||||
entries: ReturnType<typeof catalogEntry>[],
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -214,6 +214,16 @@ export const CrossProviderOverview = async ({
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
|
||||
{catalog.incomplete && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription>
|
||||
Some compliance frameworks could not be listed. Showing the ones
|
||||
that loaded — try again later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loadErrors.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="size-4" />
|
||||
@@ -226,6 +236,7 @@ export const CrossProviderOverview = async ({
|
||||
)}
|
||||
|
||||
{loadErrors.length === 0 &&
|
||||
!catalog.incomplete &&
|
||||
summaries.every((summary) => summary.totalRequirements === 0) && (
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <TResponse>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CrossProviderCatalog> => {
|
||||
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 };
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user