mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
3554859a5c
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>
192 lines
5.3 KiB
TypeScript
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);
|
|
});
|
|
});
|