fix(ui): load every Attack Paths scan before displaying the selector (#10864)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-04-24 09:41:47 +02:00
committed by GitHub
parent 80d62f355f
commit 3554859a5c
7 changed files with 392 additions and 27 deletions
+2 -1
View File
@@ -18,11 +18,12 @@ All notable changes to the **Prowler UI** are documented in this file.
---
## [1.24.4] (Prowler UNRELEASED)
## [1.24.4] (Prowler 5.24.4)
### 🐞 Fixed
- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851)
- Attack Paths scan selector now lists scans from every provider with working pagination, instead of capping the list at the first ten [(#10864)](https://github.com/prowler-cloud/prowler/pull/10864)
---
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
type AttackPathScan,
type AttackPathScansResponse,
SCAN_STATES,
} from "@/types/attack-paths";
import { adaptAttackPathScansResponse } from "./scans.adapter";
const makeScan = (
id: string,
overrides: Partial<AttackPathScan["attributes"]> = {},
): AttackPathScan => ({
type: "attack-paths-scans",
id,
attributes: {
state: SCAN_STATES.COMPLETED,
progress: 100,
graph_data_ready: true,
provider_alias: `alias-${id}`,
provider_type: "aws",
provider_uid: id,
inserted_at: "2026-04-23T10:00:00Z",
started_at: "2026-04-23T10:00:00Z",
completed_at: "2026-04-23T10:10:00Z",
duration: 600,
...overrides,
},
relationships: {} as AttackPathScan["relationships"],
});
describe("adaptAttackPathScansResponse", () => {
it("returns an empty list when the response is undefined", () => {
// When
const result = adaptAttackPathScansResponse(undefined);
// Then
expect(result).toEqual({ data: [] });
});
it("enriches each scan with durationLabel and isRecent", () => {
// Given a scan that completed recently
const recentCompletion = new Date(
Date.now() - 60 * 60 * 1000,
).toISOString();
const response: AttackPathScansResponse = {
data: [makeScan("s1", { completed_at: recentCompletion, duration: 90 })],
links: { first: "", last: "", next: null, prev: null },
meta: { pagination: { page: 1, pages: 1, count: 1 } },
};
// When
const result = adaptAttackPathScansResponse(response);
// Then
expect(result.data).toHaveLength(1);
const enriched = result.data[0]
.attributes as (typeof result.data)[0]["attributes"] & {
durationLabel: string | null;
isRecent: boolean;
};
expect(enriched.durationLabel).toBeDefined();
expect(enriched.isRecent).toBe(true);
});
it("surfaces meta.pagination values unchanged in the adapted metadata", () => {
// Given a paginated API response
const response: AttackPathScansResponse = {
data: [makeScan("s1"), makeScan("s2")],
links: { first: "", last: "", next: null, prev: null },
meta: { pagination: { page: 3, pages: 5, count: 42 }, version: "2.0" },
};
// When
const result = adaptAttackPathScansResponse(response);
// Then
expect(result.metadata).toEqual({
pagination: {
page: 3,
pages: 5,
count: 42,
itemsPerPage: [5, 10, 25, 50, 100],
},
version: "2.0",
});
});
it("omits metadata when the response has no pagination info", () => {
// Given
const response: AttackPathScansResponse = {
data: [makeScan("s1")],
links: { first: "", last: "", next: null, prev: null },
};
// When
const result = adaptAttackPathScansResponse(response);
// Then
expect(result.metadata).toBeUndefined();
});
});
+6 -9
View File
@@ -44,18 +44,15 @@ export function adaptAttackPathScansResponse(
},
}));
// Transform links to MetaDataProps format if pagination exists
const metadata: MetaDataProps | undefined = response.links
const metadata: MetaDataProps | undefined = response.meta?.pagination
? {
pagination: {
// Links-based pagination doesn't have traditional page numbers
// but we preserve the structure for consistency
page: 1,
pages: 1,
count: enrichedData.length,
itemsPerPage: [10, 25, 50, 100],
page: response.meta.pagination.page,
pages: response.meta.pagination.pages,
count: response.meta.pagination.count,
itemsPerPage: [5, 10, 25, 50, 100],
},
version: "1.0",
version: response.meta.version ?? "1.0",
}
: undefined;
+191
View File
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type AttackPathScan,
type AttackPathScansResponse,
SCAN_STATES,
} from "@/types/attack-paths";
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 { getAttackPathScans } from "./scans";
const makeScan = (id: string): AttackPathScan => ({
type: "attack-paths-scans",
id,
attributes: {
state: SCAN_STATES.COMPLETED,
progress: 100,
graph_data_ready: true,
provider_alias: `alias-${id}`,
provider_type: "aws",
provider_uid: id,
inserted_at: "2026-04-23T10:00:00Z",
started_at: "2026-04-23T10:00:00Z",
completed_at: "2026-04-23T10:10:00Z",
duration: 600,
},
relationships: {} as AttackPathScan["relationships"],
});
const pageResponse = (
ids: string[],
page: number,
pages: number,
count: number,
): AttackPathScansResponse => ({
data: ids.map(makeScan),
links: {
first: "first",
last: "last",
next: page < pages ? "next" : null,
prev: page > 1 ? "prev" : null,
},
meta: {
pagination: { page, pages, count },
},
});
const getFetchedPageNumber = (call: unknown[]) => {
const url = new URL(String(call[0]));
return Number(url.searchParams.get("page[number]"));
};
describe("getAttackPathScans", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("requests page[size]=100 with page[number]=1 on the first call", async () => {
// Given
handleApiResponseMock.mockResolvedValueOnce(pageResponse(["s1"], 1, 1, 1));
// When
await getAttackPathScans();
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const url = new URL(String(call[0]));
expect(url.pathname).toBe("/api/v1/attack-paths-scans");
expect(url.searchParams.get("page[number]")).toBe("1");
expect(url.searchParams.get("page[size]")).toBe("100");
});
it("iterates across every backend page and aggregates all scans", async () => {
// Given three pages totalling 22 scans
handleApiResponseMock
.mockResolvedValueOnce(
pageResponse(
Array.from({ length: 10 }, (_, i) => `a${i}`),
1,
3,
22,
),
)
.mockResolvedValueOnce(
pageResponse(
Array.from({ length: 10 }, (_, i) => `b${i}`),
2,
3,
22,
),
)
.mockResolvedValueOnce(pageResponse(["c0", "c1"], 3, 3, 22));
// When
const result = await getAttackPathScans();
// Then
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls.map(getFetchedPageNumber)).toEqual([1, 2, 3]);
expect(result?.data).toHaveLength(22);
});
it("stops requesting when the current page equals meta.pagination.pages", async () => {
// Given a single-page response
handleApiResponseMock.mockResolvedValueOnce(
pageResponse(["only"], 1, 1, 1),
);
// When
const result = await getAttackPathScans();
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result?.data).toHaveLength(1);
});
it("stops when a page returns an empty data array", async () => {
// Given the second page is unexpectedly empty
handleApiResponseMock
.mockResolvedValueOnce(pageResponse(["a0"], 1, 3, 3))
.mockResolvedValueOnce({
data: [],
links: { first: "", last: "", next: null, prev: null },
meta: { pagination: { page: 2, pages: 3, count: 3 } },
} as AttackPathScansResponse);
// When
const result = await getAttackPathScans();
// Then
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result?.data).toHaveLength(1);
});
it("returns undefined when the first fetch throws", async () => {
// Given
handleApiResponseMock.mockRejectedValueOnce(new Error("network down"));
// When
const result = await getAttackPathScans();
// Then
expect(result).toBeUndefined();
});
it("preserves scans from earlier pages when a later fetch throws", async () => {
// Given the first page resolves but the second page errors mid-iteration
handleApiResponseMock
.mockResolvedValueOnce(pageResponse(["a0", "a1"], 1, 3, 5))
.mockRejectedValueOnce(new Error("network blip"));
// When
const result = await getAttackPathScans();
// Then we keep the scans we already fetched instead of discarding everything
expect(result?.data).toHaveLength(2);
expect(result?.data.map((scan) => scan.id)).toEqual(["a0", "a1"]);
});
it("returns an empty list when the first page has no data", async () => {
// Given
handleApiResponseMock.mockResolvedValueOnce(undefined);
// When
const result = await getAttackPathScans();
// Then
expect(result).toEqual({ data: [] });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+61 -15
View File
@@ -8,33 +8,79 @@ import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
import { adaptAttackPathScansResponse } from "./scans.adapter";
// Validation schema for UUID - RFC 9562/4122 compliant
const UUIDSchema = z.uuid();
const ATTACK_PATH_SCANS_PAGE_SIZE = 100;
const ATTACK_PATH_SCANS_MAX_PAGES = 50;
/**
* Fetch list of attack path scans (latest scan for each provider)
* Fetch list of attack path scans (latest scan for each provider).
*
* Iterates through every backend page so callers receive the complete
* dedup'd dataset along with an accurate total count. The underlying
* endpoint is paginated server-side (default page_size=10), so fetching
* only the first page would silently hide providers beyond that window.
*/
export const getAttackPathScans = async (): Promise<
{ data: AttackPathScan[] } | undefined
> => {
const headers = await getAuthHeaders({ contentType: false });
const allScans: AttackPathScan[] = [];
let currentPage = 1;
let lastResponse: AttackPathScansResponse | undefined;
let hasMorePages = true;
try {
const response = await fetch(`${apiBaseUrl}/attack-paths-scans`, {
headers,
method: "GET",
});
while (hasMorePages && currentPage <= ATTACK_PATH_SCANS_MAX_PAGES) {
try {
const url = new URL(`${apiBaseUrl}/attack-paths-scans`);
url.searchParams.append("page[number]", currentPage.toString());
url.searchParams.append(
"page[size]",
ATTACK_PATH_SCANS_PAGE_SIZE.toString(),
);
const apiResponse = (await handleApiResponse(
response,
)) as AttackPathScansResponse;
const adaptedData = adaptAttackPathScansResponse(apiResponse);
const response = await fetch(url.toString(), {
headers,
method: "GET",
});
return { data: adaptedData.data };
} catch (error) {
console.error("Error fetching attack path scans:", error);
return undefined;
const data = (await handleApiResponse(response)) as
| AttackPathScansResponse
| undefined;
if (!data?.data || data.data.length === 0) {
hasMorePages = false;
continue;
}
allScans.push(...data.data);
lastResponse = data;
const totalPages = data.meta?.pagination?.pages ?? 1;
if (currentPage >= totalPages) {
hasMorePages = false;
} else {
currentPage++;
}
} catch (error) {
console.error("Error fetching attack path scans:", error);
if (allScans.length === 0) {
return undefined;
}
break;
}
}
if (!lastResponse) {
return { data: [] };
}
const adapted = adaptAttackPathScansResponse({
...lastResponse,
data: allScans,
});
return { data: adapted.data };
};
/**
@@ -3,6 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
import {
RadioGroup,
@@ -28,8 +29,8 @@ interface ScanListTableProps {
scans: AttackPathScan[];
}
const DEFAULT_PAGE_SIZE = 5;
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
const DEFAULT_PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 25];
const parsePageParam = (value: string | null, fallback: number) => {
if (!value) return fallback;
@@ -243,6 +244,15 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
const endIndex = startIndex + pageSize;
const paginatedScans = scans.slice(startIndex, endIndex);
// TODO(#10863): remove this workaround (ref + split handlers + pushWithParams)
// once the DataTable unified-pagination-callback refactor in PR #10863 lands.
// The underlying issue is that DataTablePagination's controlled mode fires
// onPageSizeChange and onPageChange(1) back-to-back in the same tick, so the
// second router.push reads a stale searchParams snapshot and silently reverts
// the page-size change. Replace both handlers with a single
// onPaginationChange handler after that PR merges.
const suppressNextPageResetRef = useRef(false);
const pushWithParams = (nextParams: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
@@ -258,10 +268,15 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
};
const handlePageChange = (page: number) => {
if (suppressNextPageResetRef.current && page === 1) {
suppressNextPageResetRef.current = false;
return;
}
pushWithParams({ scanPage: page.toString() });
};
const handlePageSizeChange = (nextPageSize: number) => {
suppressNextPageResetRef.current = true;
pushWithParams({
scanPage: "1",
scanPageSize: nextPageSize.toString(),
+12
View File
@@ -67,9 +67,21 @@ export interface PaginationLinks {
prev: string | null;
}
export interface AttackPathScansResponsePagination {
page: number;
pages: number;
count: number;
}
export interface AttackPathScansResponseMeta {
pagination: AttackPathScansResponsePagination;
version?: string;
}
export interface AttackPathScansResponse {
data: AttackPathScan[];
links: PaginationLinks;
meta?: AttackPathScansResponseMeta;
}
// Data type constants