mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-01 05:37:14 +00:00
fix(ui): limit concurrent scan launches in launchOrganizationScans
Replace unbounded Promise.allSettled with runWithConcurrencyLimit capped at 5 concurrent requests. Move error handling into the worker callback. Add unit test verifying concurrency ceiling.
This commit is contained in:
71
ui/actions/scans/scans.test.ts
Normal file
71
ui/actions/scans/scans.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sentry-breadcrumbs", () => ({
|
||||
addScanOperation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { launchOrganizationScans } from "./scans";
|
||||
|
||||
describe("launchOrganizationScans", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "scan-id" } });
|
||||
handleApiErrorMock.mockReturnValue({ error: "Scan launch failed." });
|
||||
});
|
||||
|
||||
it("limits concurrent launch requests to avoid overwhelming the backend", async () => {
|
||||
// Given
|
||||
const providerIds = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `provider-${index + 1}`,
|
||||
);
|
||||
let activeRequests = 0;
|
||||
let maxActiveRequests = 0;
|
||||
|
||||
fetchMock.mockImplementation(async () => {
|
||||
activeRequests += 1;
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
activeRequests -= 1;
|
||||
|
||||
return new Response(JSON.stringify({ data: { id: "scan-id" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await launchOrganizationScans(providerIds, "daily");
|
||||
|
||||
// Then
|
||||
expect(maxActiveRequests).toBeLessThanOrEqual(5);
|
||||
expect(result.successCount).toBe(providerIds.length);
|
||||
expect(result.failureCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,41 @@ import {
|
||||
} from "@/lib/provider-filters";
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
|
||||
|
||||
export async function runWithConcurrencyLimit<T, R>(
|
||||
items: T[],
|
||||
concurrencyLimit: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
|
||||
const results = new Array<R>(items.length);
|
||||
let currentIndex = 0;
|
||||
|
||||
const runWorker = async () => {
|
||||
while (currentIndex < items.length) {
|
||||
const assignedIndex = currentIndex;
|
||||
currentIndex += 1;
|
||||
results[assignedIndex] = await worker(
|
||||
items[assignedIndex],
|
||||
assignedIndex,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(normalizedConcurrency, items.length) },
|
||||
() => runWorker(),
|
||||
);
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
export const getScans = async ({
|
||||
page = 1,
|
||||
query = "",
|
||||
@@ -178,45 +213,46 @@ export const launchOrganizationScans = async (
|
||||
};
|
||||
}
|
||||
|
||||
const launchPromises = validProviderIds.map(async (providerId) => {
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
const launchResults = await runWithConcurrencyLimit(
|
||||
validProviderIds,
|
||||
ORGANIZATION_SCAN_CONCURRENCY_LIMIT,
|
||||
async (providerId) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
|
||||
const result =
|
||||
scheduleOption === "daily"
|
||||
? await scheduleDaily(formData)
|
||||
: await scanOnDemand(formData);
|
||||
const result =
|
||||
scheduleOption === "daily"
|
||||
? await scheduleDaily(formData)
|
||||
: await scanOnDemand(formData);
|
||||
|
||||
return {
|
||||
providerId,
|
||||
ok: !result?.error,
|
||||
error: result?.error ? String(result.error) : null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
providerId,
|
||||
ok: !result?.error,
|
||||
error: result?.error ? String(result.error) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
providerId,
|
||||
ok: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to launch scan.",
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(launchPromises);
|
||||
const summary = settled.reduce(
|
||||
const summary = launchResults.reduce(
|
||||
(acc, item) => {
|
||||
if (item.status === "fulfilled") {
|
||||
if (item.value.ok) {
|
||||
acc.successCount += 1;
|
||||
} else {
|
||||
acc.failureCount += 1;
|
||||
acc.errors.push({
|
||||
providerId: item.value.providerId,
|
||||
error: item.value.error || "Failed to launch scan.",
|
||||
});
|
||||
}
|
||||
if (item.ok) {
|
||||
acc.successCount += 1;
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.failureCount += 1;
|
||||
acc.errors.push({
|
||||
providerId: "",
|
||||
error:
|
||||
item.reason instanceof Error
|
||||
? item.reason.message
|
||||
: String(item.reason),
|
||||
providerId: item.providerId,
|
||||
error: item.error || "Failed to launch scan.",
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user