Files
Pablo Fernandez Guerra (PFE) 3554859a5c 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>
2026-04-24 09:41:47 +02:00

192 lines
5.3 KiB
TypeScript

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);
});
});