fix(ui): keep compliance tab when one catalog page fails (#13183)

This commit is contained in:
Pedro Martín authored and GitHub committed 2026-10-28 09:51:58 +01:00
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
+1 -1
View File
@@ -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 ||
+2
View File
@@ -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 {