From 2d535b19cccdb02d22c651bdb064644412e5ef94 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 4 Aug 2026 15:08:43 +0200 Subject: [PATCH] refactor(ui): type overview action results as ApiResult (#12331) Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> --- .../attack-surface/attack-surface.adapter.ts | 4 +- .../overview/attack-surface/attack-surface.ts | 3 +- .../compliance-watchlist.adapter.ts | 3 +- .../compliance-watchlist.ts | 3 +- ui/actions/overview/findings/findings.ts | 10 ++- .../overview/findings/types/findings.types.ts | 28 +++++++++ ui/actions/overview/providers/providers.ts | 3 +- ui/actions/overview/regions/regions.ts | 3 +- .../overview/regions/threat-map.adapter.ts | 3 +- .../resources-inventory.adapter.ts | 4 +- .../resources-inventory.ts | 3 +- .../overview/risk-radar/risk-radar.adapter.ts | 3 +- ui/actions/overview/risk-radar/risk-radar.ts | 3 +- ui/actions/overview/services/services.ts | 3 +- .../severity-trends/severity-trends.test.ts | 61 +++++++++++++++++++ .../severity-trends/severity-trends.ts | 11 +++- .../overview/threat-score/threat-score.ts | 5 +- .../threat-score/threat-score.ssr.test.tsx | 4 +- .../compliance/[compliancetitle]/page.tsx | 8 ++- ui/types/server-actions.ts | 20 ++++++ 20 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 ui/actions/overview/severity-trends/severity-trends.test.ts diff --git a/ui/actions/overview/attack-surface/attack-surface.adapter.ts b/ui/actions/overview/attack-surface/attack-surface.adapter.ts index bea25ce11b..1d6d2821b9 100644 --- a/ui/actions/overview/attack-surface/attack-surface.adapter.ts +++ b/ui/actions/overview/attack-surface/attack-surface.adapter.ts @@ -1,3 +1,5 @@ +import type { ApiResult } from "@/types/server-actions"; + import { AttackSurfaceOverview, AttackSurfaceOverviewResponse } from "./types"; const ATTACK_SURFACE_IDS = { @@ -49,7 +51,7 @@ function mapAttackSurfaceItem(item: AttackSurfaceOverview): AttackSurfaceItem { * @returns An array of AttackSurfaceItem objects sorted by the predefined order */ export function adaptAttackSurfaceOverview( - response: AttackSurfaceOverviewResponse | undefined, + response: ApiResult | undefined, ): AttackSurfaceItem[] { if (!response?.data || response.data.length === 0) { return []; diff --git a/ui/actions/overview/attack-surface/attack-surface.ts b/ui/actions/overview/attack-surface/attack-surface.ts index 4a97a406d4..6e77e88957 100644 --- a/ui/actions/overview/attack-surface/attack-surface.ts +++ b/ui/actions/overview/attack-surface/attack-surface.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { AttackSurfaceOverviewResponse } from "./types"; @@ -10,7 +11,7 @@ export const getAttackSurfaceOverview = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`); diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts index bc8005edf4..037f00e359 100644 --- a/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts @@ -1,5 +1,6 @@ import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; import { formatLabel } from "@/lib/categories"; +import type { ApiResult } from "@/types/server-actions"; import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; @@ -36,7 +37,7 @@ function formatComplianceLabel(complianceId: string): string { } export function adaptComplianceWatchlistResponse( - response: ComplianceWatchlistResponse | undefined, + response: ApiResult | undefined, ): EnrichedComplianceWatchlistItem[] { if (!response?.data) { return []; diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts index 026a1b426b..66fe600106 100644 --- a/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; @@ -10,7 +11,7 @@ export const getComplianceWatchlist = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`); diff --git a/ui/actions/overview/findings/findings.ts b/ui/actions/overview/findings/findings.ts index 2aa771fb26..68a8326980 100644 --- a/ui/actions/overview/findings/findings.ts +++ b/ui/actions/overview/findings/findings.ts @@ -5,8 +5,12 @@ import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; -import { FindingsSeverityOverviewResponse } from "./types"; +import { + FindingsSeverityOverviewResponse, + FindingsStatusOverviewResponse, +} from "./types"; export const getFindingsByStatus = async ({ page = 1, @@ -18,7 +22,7 @@ export const getFindingsByStatus = async ({ query?: string; sort?: string; filters?: Record; -} = {}) => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/"); @@ -49,7 +53,7 @@ export const getFindingsBySeverity = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/findings_severity`); diff --git a/ui/actions/overview/findings/types/findings.types.ts b/ui/actions/overview/findings/types/findings.types.ts index a78e865538..6d3de89edd 100644 --- a/ui/actions/overview/findings/types/findings.types.ts +++ b/ui/actions/overview/findings/types/findings.types.ts @@ -5,6 +5,34 @@ interface OverviewResponseMeta { version: string; } +// Corresponds to the /overviews/findings endpoint (OverviewFindingSerializer) +export interface FindingsStatusAttributes { + new: number; + changed: number; + unchanged: number; + fail_new: number; + fail_changed: number; + pass_new: number; + pass_changed: number; + muted_new: number; + muted_changed: number; + total: number; + pass: number; + fail: number; + muted: number; +} + +export interface FindingsStatusOverview { + type: "findings-overview"; + id: string; + attributes: FindingsStatusAttributes; +} + +export interface FindingsStatusOverviewResponse { + data: FindingsStatusOverview; + meta: OverviewResponseMeta; +} + export interface FindingsSeverityAttributes { critical: number; high: number; diff --git a/ui/actions/overview/providers/providers.ts b/ui/actions/overview/providers/providers.ts index 9aaec5df8c..c6e2d14265 100644 --- a/ui/actions/overview/providers/providers.ts +++ b/ui/actions/overview/providers/providers.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { ProvidersOverviewResponse } from "./types"; @@ -18,7 +19,7 @@ export const getProvidersOverview = async ({ query?: string; sort?: string; filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/providers-overview"); diff --git a/ui/actions/overview/regions/regions.ts b/ui/actions/overview/regions/regions.ts index 31610892ef..75cf792771 100644 --- a/ui/actions/overview/regions/regions.ts +++ b/ui/actions/overview/regions/regions.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { RegionsOverviewResponse } from "./types"; @@ -10,7 +11,7 @@ export const getRegionsOverview = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/regions`); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 6ee0958aad..130803a732 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -1,4 +1,5 @@ import { getProviderDisplayName } from "@/types/providers"; +import type { ApiResult } from "@/types/server-actions"; import { RegionsOverviewResponse } from "./types"; @@ -366,7 +367,7 @@ function formatRegionName(providerType: string, region: string): string { * Adapts regions overview API response to threat map format. */ export function adaptRegionsOverviewToThreatMap( - regionsResponse: RegionsOverviewResponse | undefined, + regionsResponse: ApiResult | undefined, ): ThreatMapData { if (!regionsResponse?.data || regionsResponse.data.length === 0) { return { diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts index 508d9a46ad..629d28d4c6 100644 --- a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts +++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts @@ -19,6 +19,8 @@ import { Webhook, } from "lucide-react"; +import type { ApiResult } from "@/types/server-actions"; + import { ResourceGroupOverview, ResourceGroupOverviewResponse, @@ -191,7 +193,7 @@ function formatResourceGroupLabel(id: string): string { * @returns An array of ResourceInventoryItem objects sorted by the predefined order */ export function adaptResourceGroupOverview( - response: ResourceGroupOverviewResponse | undefined, + response: ApiResult | undefined, ): ResourceInventoryItem[] { if (!response?.data || response.data.length === 0) { return []; diff --git a/ui/actions/overview/resources-inventory/resources-inventory.ts b/ui/actions/overview/resources-inventory/resources-inventory.ts index 264da6e375..07ada88fcc 100644 --- a/ui/actions/overview/resources-inventory/resources-inventory.ts +++ b/ui/actions/overview/resources-inventory/resources-inventory.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { ResourceGroupOverviewResponse } from "./types"; @@ -10,7 +11,7 @@ export const getResourceGroupOverview = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/resource-groups`); diff --git a/ui/actions/overview/risk-radar/risk-radar.adapter.ts b/ui/actions/overview/risk-radar/risk-radar.adapter.ts index 0859d434e5..16f2f7a2ea 100644 --- a/ui/actions/overview/risk-radar/risk-radar.adapter.ts +++ b/ui/actions/overview/risk-radar/risk-radar.adapter.ts @@ -1,5 +1,6 @@ import type { RadarDataPoint } from "@/components/graphs/types"; import { getCategoryLabel } from "@/lib/categories"; +import type { ApiResult } from "@/types/server-actions"; import { CategoryOverview, CategoryOverviewResponse } from "./types"; @@ -44,7 +45,7 @@ function mapCategoryToRadarPoint(item: CategoryOverview): RadarDataPoint { * @returns An array of RadarDataPoint objects for the radar chart */ export function adaptCategoryOverviewToRadarData( - response: CategoryOverviewResponse | undefined, + response: ApiResult | undefined, ): RadarDataPoint[] { if (!response?.data || response.data.length === 0) { return []; diff --git a/ui/actions/overview/risk-radar/risk-radar.ts b/ui/actions/overview/risk-radar/risk-radar.ts index 419330dc18..cd91108729 100644 --- a/ui/actions/overview/risk-radar/risk-radar.ts +++ b/ui/actions/overview/risk-radar/risk-radar.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { CategoryOverviewResponse } from "./types"; @@ -10,7 +11,7 @@ export const getCategoryOverview = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/categories`); diff --git a/ui/actions/overview/services/services.ts b/ui/actions/overview/services/services.ts index 1e55c04531..1086de60bc 100644 --- a/ui/actions/overview/services/services.ts +++ b/ui/actions/overview/services/services.ts @@ -3,6 +3,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { ServicesOverviewResponse } from "./types"; @@ -10,7 +11,7 @@ export const getServicesOverview = async ({ filters = {}, }: { filters?: Record; -} = {}): Promise => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/services`); diff --git a/ui/actions/overview/severity-trends/severity-trends.test.ts b/ui/actions/overview/severity-trends/severity-trends.test.ts new file mode 100644 index 0000000000..99b079adc4 --- /dev/null +++ b/ui/actions/overview/severity-trends/severity-trends.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { getFindingsSeverityTrends } from "./severity-trends"; + +describe("getFindingsSeverityTrends", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("returns an error status on a 4xx response shape", async () => { + // handleApiResponse resolves truthy {error, status} objects for 4xx. + handleApiResponseMock.mockResolvedValueOnce({ + error: "Invalid filter", + status: 400, + }); + + const result = await getFindingsSeverityTrends(); + + expect(result).toEqual({ status: "error" }); + }); + + it("returns an empty status on a no-content response shape", async () => { + // handleApiResponse resolves {success, status} for 204 and empty bodies. + handleApiResponseMock.mockResolvedValueOnce({ + success: true, + status: 204, + }); + + const result = await getFindingsSeverityTrends(); + + expect(result).toEqual({ status: "empty" }); + }); + + it("returns an empty status when the trend list has no entries", async () => { + handleApiResponseMock.mockResolvedValueOnce({ data: [] }); + + const result = await getFindingsSeverityTrends(); + + expect(result).toEqual({ status: "empty" }); + }); +}); diff --git a/ui/actions/overview/severity-trends/severity-trends.ts b/ui/actions/overview/severity-trends/severity-trends.ts index e90efa15b7..156d4c72d5 100644 --- a/ui/actions/overview/severity-trends/severity-trends.ts +++ b/ui/actions/overview/severity-trends/severity-trends.ts @@ -7,6 +7,7 @@ import { import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; import { adaptSeverityTrendsResponse } from "./severity-trends.adapter"; import { @@ -35,10 +36,16 @@ const getFindingsSeverityTrends = async ({ headers, }); - const apiResponse: FindingsSeverityOverTimeResponse | undefined = + const apiResponse: ApiResult | undefined = await handleApiResponse(response); - if (!apiResponse?.data || !Array.isArray(apiResponse.data)) { + // 4xx resolves a truthy {error, status} shape — surface it as an error + // instead of misreporting the trend as empty. + if (!apiResponse || "error" in apiResponse) { + return { status: "error" }; + } + + if (!apiResponse.data || !Array.isArray(apiResponse.data)) { return { status: "empty" }; } diff --git a/ui/actions/overview/threat-score/threat-score.ts b/ui/actions/overview/threat-score/threat-score.ts index 0a1d7505d1..e966ebab06 100644 --- a/ui/actions/overview/threat-score/threat-score.ts +++ b/ui/actions/overview/threat-score/threat-score.ts @@ -3,12 +3,15 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; + +import type { ThreatScoreResponse } from "./types"; export const getThreatScore = async ({ filters = {}, }: { filters?: Record; -} = {}) => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/overviews/threatscore`); diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx index 546d022db1..b23d97b52d 100644 --- a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx +++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx @@ -54,7 +54,7 @@ describe("ThreatScoreSSR", () => { }, }, ], - }); + } as unknown as Awaited>); render(await ThreatScoreSSR({ searchParams: {} })); @@ -91,7 +91,7 @@ describe("ThreatScoreSSR", () => { }, }, ], - }); + } as unknown as Awaited>); render(await ThreatScoreSSR({ searchParams: {} })); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index d5ad1b1c5b..9ac9304068 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -267,7 +267,13 @@ export default async function ComplianceDetail({ const snapshot = threatScoreResponse.data[0]; threatScoreData = { overallScore: parseFloat(snapshot.attributes.overall_score), - sectionScores: snapshot.attributes.section_scores, + // The multi-provider aggregation branch serializes section scores as + // decimal strings. + sectionScores: Object.fromEntries( + Object.entries(snapshot.attributes.section_scores).map( + ([name, value]) => [name, Number(value)], + ), + ), }; } } diff --git a/ui/types/server-actions.ts b/ui/types/server-actions.ts index db03907d16..42a445c3ff 100644 --- a/ui/types/server-actions.ts +++ b/ui/types/server-actions.ts @@ -15,3 +15,23 @@ export interface ServerActionFailure { export type ServerActionResult = | ServerActionSuccess | ServerActionFailure; + +export interface ApiErrorResult extends ServerActionFailure { + status: number; + data?: never; +} + +export interface ApiNoContentResult { + success: true; + status: number; + data?: never; +} + +// handleApiResponse resolves the raw JSON:API payload on success, but also +// resolves truthy no-data shapes: {error, status} for 4xx and +// {success, status} for 204/empty bodies. `data?: never` on those members +// keeps `result?.data?.attributes` guards compiling and narrowing correctly. +export type ApiResult = + | TResponse + | ApiErrorResult + | ApiNoContentResult;