From 20badce9e321e5690bd3f18bc141f224356bc5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:08:12 +0200 Subject: [PATCH] fix(ui): load every Attack Paths scan before displaying the selector (#10864) (#10877) Co-authored-by: Pablo Fernandez Guerra (PFE) <148432447+pfe-nazaries@users.noreply.github.com> Co-authored-by: Pablo F.G Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Co-authored-by: Pepe Fagoaga --- ui/CHANGELOG.md | 3 +- ui/actions/attack-paths/scans.adapter.test.ts | 103 ++++++++++ ui/actions/attack-paths/scans.adapter.ts | 15 +- ui/actions/attack-paths/scans.test.ts | 191 ++++++++++++++++++ ui/actions/attack-paths/scans.ts | 76 +++++-- .../_components/scan-list-table.tsx | 19 +- ui/types/attack-paths.ts | 12 ++ 7 files changed, 392 insertions(+), 27 deletions(-) create mode 100644 ui/actions/attack-paths/scans.adapter.test.ts create mode 100644 ui/actions/attack-paths/scans.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index afc93eee63..d9eccc0df9 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,11 +12,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) --- diff --git a/ui/actions/attack-paths/scans.adapter.test.ts b/ui/actions/attack-paths/scans.adapter.test.ts new file mode 100644 index 0000000000..aa7ef1f988 --- /dev/null +++ b/ui/actions/attack-paths/scans.adapter.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts index e6ef4168e2..02456e58fd 100644 --- a/ui/actions/attack-paths/scans.adapter.ts +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -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; diff --git a/ui/actions/attack-paths/scans.test.ts b/ui/actions/attack-paths/scans.test.ts new file mode 100644 index 0000000000..a4ae05bbcf --- /dev/null +++ b/ui/actions/attack-paths/scans.test.ts @@ -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); + }); +}); diff --git a/ui/actions/attack-paths/scans.ts b/ui/actions/attack-paths/scans.ts index 11342f6ad3..ec3bc927af 100644 --- a/ui/actions/attack-paths/scans.ts +++ b/ui/actions/attack-paths/scans.ts @@ -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 }; }; /** diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index aa686be15c..c13e0ae994 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -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) => { 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(), diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts index 3b999d8881..5aecdec671 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -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