mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
refactor(ui): type overview action results as ApiResult (#12287)
This commit is contained in:
@@ -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<AttackSurfaceOverviewResponse> | undefined,
|
||||
): AttackSurfaceItem[] {
|
||||
if (!response?.data || response.data.length === 0) {
|
||||
return [];
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<AttackSurfaceOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<AttackSurfaceOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`);
|
||||
|
||||
@@ -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<ComplianceWatchlistResponse> | undefined,
|
||||
): EnrichedComplianceWatchlistItem[] {
|
||||
if (!response?.data) {
|
||||
return [];
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<ComplianceWatchlistResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<ComplianceWatchlistResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`);
|
||||
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}) => {
|
||||
} = {}): Promise<ApiResult<FindingsStatusOverviewResponse> | 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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<FindingsSeverityOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<ProvidersOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<RegionsOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<RegionsOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/regions`);
|
||||
|
||||
@@ -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<RegionsOverviewResponse> | undefined,
|
||||
): ThreatMapData {
|
||||
if (!regionsResponse?.data || regionsResponse.data.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -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<ResourceGroupOverviewResponse> | undefined,
|
||||
): ResourceInventoryItem[] {
|
||||
if (!response?.data || response.data.length === 0) {
|
||||
return [];
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<ResourceGroupOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<ResourceGroupOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/resource-groups`);
|
||||
|
||||
@@ -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<CategoryOverviewResponse> | undefined,
|
||||
): RadarDataPoint[] {
|
||||
if (!response?.data || response.data.length === 0) {
|
||||
return [];
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<CategoryOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<CategoryOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/categories`);
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}): Promise<ServicesOverviewResponse | undefined> => {
|
||||
} = {}): Promise<ApiResult<ServicesOverviewResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/services`);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<FindingsSeverityOverTimeResponse> | 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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
} = {}) => {
|
||||
} = {}): Promise<ApiResult<ThreatScoreResponse> | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("ThreatScoreSSR", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} as unknown as Awaited<ReturnType<typeof getThreatScore>>);
|
||||
|
||||
render(await ThreatScoreSSR({ searchParams: {} }));
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("ThreatScoreSSR", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} as unknown as Awaited<ReturnType<typeof getThreatScore>>);
|
||||
|
||||
render(await ThreatScoreSSR({ searchParams: {} }));
|
||||
|
||||
|
||||
@@ -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)],
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,23 @@ export interface ServerActionFailure {
|
||||
export type ServerActionResult<TData> =
|
||||
| ServerActionSuccess<TData>
|
||||
| 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> =
|
||||
| TResponse
|
||||
| ApiErrorResult
|
||||
| ApiNoContentResult;
|
||||
|
||||
Reference in New Issue
Block a user