mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(ui): launch organization scans through the bulk endpoint (#12350)
This commit is contained in:
@@ -624,9 +624,22 @@ export const handlersForOrganizations = (
|
||||
}),
|
||||
|
||||
// --- launch (scans + schedules) --------------------------------------
|
||||
http.post(`${API}/scans`, () =>
|
||||
http.post(`${API}/scans/bulk`, () =>
|
||||
HttpResponse.json(
|
||||
{ data: { id: "scan-1", type: "scans", attributes: {} } },
|
||||
{
|
||||
data: fx.apply.createdProviderIds.map((providerId, index) => ({
|
||||
id: `scan-${index + 1}`,
|
||||
type: "scans",
|
||||
relationships: {
|
||||
provider: {
|
||||
data: { id: providerId, type: "providers" },
|
||||
},
|
||||
task: {
|
||||
data: { id: `scan-task-${index + 1}`, type: "tasks" },
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
addScanOperationMock,
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addScanOperationMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
@@ -27,12 +29,89 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sentry-breadcrumbs", () => ({
|
||||
addScanOperation: vi.fn(),
|
||||
addScanOperation: addScanOperationMock,
|
||||
}));
|
||||
|
||||
import { getExportsZip, launchOrganizationScans } from "./scans";
|
||||
import {
|
||||
getExportsZip,
|
||||
launchOrganizationScans,
|
||||
scheduleOrganizationDailyScans,
|
||||
} from "./scans";
|
||||
|
||||
describe("launchOrganizationScans", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [{ id: "scan-1" }] });
|
||||
});
|
||||
|
||||
it("sends one organization bulk scan request", async () => {
|
||||
// Given
|
||||
const scans = [
|
||||
{ id: "scan-1", type: "scans" },
|
||||
{ id: "scan-2", type: "scans" },
|
||||
];
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 202 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: scans });
|
||||
|
||||
// When
|
||||
const result = await launchOrganizationScans("organization-1");
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/scans/bulk",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "scans-bulk",
|
||||
relationships: {
|
||||
organization: {
|
||||
data: {
|
||||
type: "organizations",
|
||||
id: "organization-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(handleApiResponseMock).toHaveBeenCalledWith(
|
||||
expect.any(Response),
|
||||
"/scans",
|
||||
);
|
||||
expect(result).toEqual({ data: scans });
|
||||
expect(addScanOperationMock).toHaveBeenCalledTimes(1);
|
||||
expect(addScanOperationMock).toHaveBeenCalledWith("start", undefined, {
|
||||
organization_id: "organization-1",
|
||||
bulk: true,
|
||||
scan_count: 2,
|
||||
scan_ids: "scan-1,scan-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a successful response without a scan collection", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 202 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: { id: "scan-1", type: "scans" },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await launchOrganizationScans("organization-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "The bulk scan response did not contain a scan collection.",
|
||||
});
|
||||
expect(addScanOperationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleOrganizationDailyScans", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -63,7 +142,7 @@ describe("launchOrganizationScans", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await launchOrganizationScans(providerIds, "daily");
|
||||
const result = await scheduleOrganizationDailyScans(providerIds);
|
||||
|
||||
// Then
|
||||
expect(maxActiveRequests).toBeLessThanOrEqual(5);
|
||||
|
||||
@@ -20,6 +20,35 @@ import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
|
||||
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
|
||||
|
||||
interface OrganizationScanResource {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface OrganizationScansSuccessResponse {
|
||||
data: OrganizationScanResource[];
|
||||
}
|
||||
|
||||
interface OrganizationScansErrorResponse {
|
||||
error: unknown;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
type OrganizationScansResponse =
|
||||
| OrganizationScansSuccessResponse
|
||||
| OrganizationScansErrorResponse;
|
||||
|
||||
const isOrganizationScanResource = (
|
||||
value: unknown,
|
||||
): value is OrganizationScanResource =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string" &&
|
||||
"type" in value &&
|
||||
value.type === "scans";
|
||||
|
||||
export const getScans = async ({
|
||||
page = 1,
|
||||
query = "",
|
||||
@@ -183,9 +212,60 @@ export const scheduleDaily = async (formData: FormData) => {
|
||||
};
|
||||
|
||||
export const launchOrganizationScans = async (
|
||||
providerIds: string[],
|
||||
scheduleOption: "daily" | "single",
|
||||
) => {
|
||||
organizationId: string,
|
||||
): Promise<OrganizationScansResponse> => {
|
||||
if (!organizationId) {
|
||||
return { error: "Organization ID is required" };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/scans/bulk`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "scans-bulk",
|
||||
relationships: {
|
||||
organization: {
|
||||
data: {
|
||||
type: "organizations",
|
||||
id: organizationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse(response, "/scans");
|
||||
if (result?.error !== undefined) {
|
||||
return { error: result.error, status: result.status };
|
||||
}
|
||||
|
||||
const scans: unknown = result?.data;
|
||||
if (!Array.isArray(scans) || !scans.every(isOrganizationScanResource)) {
|
||||
return {
|
||||
error: "The bulk scan response did not contain a scan collection.",
|
||||
};
|
||||
}
|
||||
|
||||
addScanOperation("start", undefined, {
|
||||
organization_id: organizationId,
|
||||
bulk: true,
|
||||
scan_count: scans.length,
|
||||
scan_ids: scans.map((scan) => scan.id).join(","),
|
||||
});
|
||||
|
||||
return { data: scans };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleOrganizationDailyScans = async (providerIds: string[]) => {
|
||||
const validProviderIds = providerIds.filter(Boolean);
|
||||
if (validProviderIds.length === 0) {
|
||||
return {
|
||||
@@ -203,10 +283,7 @@ export const launchOrganizationScans = async (
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
|
||||
const result =
|
||||
scheduleOption === "daily"
|
||||
? await scheduleDaily(formData)
|
||||
: await scanOnDemand(formData);
|
||||
const result = await scheduleDaily(formData);
|
||||
|
||||
return {
|
||||
providerId,
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("GCP Organizations onboarding (Phase 2)", () => {
|
||||
|
||||
// These three cases pin how `/schedules/bulk`'s per-provider lists are read: a
|
||||
// client looking one level too deep sees no lists and cannot tell them apart.
|
||||
it("launches initial scans only for the projects whose schedule was saved", async () => {
|
||||
it("launches the organization after a partial schedule save", async () => {
|
||||
const harness = new ProvidersPageHarness(
|
||||
gcpOnboardingFixture({
|
||||
scheduleBulk: {
|
||||
@@ -226,7 +226,7 @@ describe("GCP Organizations onboarding (Phase 2)", () => {
|
||||
|
||||
// The reason the API gave must reach the user — a count alone is unactionable.
|
||||
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
|
||||
expect(harness.scanLaunchCount).toBe(1);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 40000);
|
||||
|
||||
it("keeps the user on the launch step when no schedule could be saved", async () => {
|
||||
@@ -252,7 +252,7 @@ describe("GCP Organizations onboarding (Phase 2)", () => {
|
||||
|
||||
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
|
||||
expect(harness.isStillOnLaunchStep()).toBe(true);
|
||||
expect(harness.scanLaunchCount).toBe(0);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(0);
|
||||
}, 40000);
|
||||
|
||||
it("proceeds when the schedule response carries no result lists", async () => {
|
||||
@@ -271,7 +271,7 @@ describe("GCP Organizations onboarding (Phase 2)", () => {
|
||||
await harness.saveScheduleAndLaunch();
|
||||
await harness.waitForLaunchComplete();
|
||||
|
||||
expect(harness.scanLaunchCount).toBe(GCP_CREATED_PROVIDER_IDS.length);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 40000);
|
||||
|
||||
it("sends a projects-only apply payload (no accounts, no organizational units)", async () => {
|
||||
|
||||
@@ -98,9 +98,9 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
return this.countRequests("POST", "/schedules/bulk");
|
||||
}
|
||||
|
||||
/** How many scans were launched (one per provider whose schedule saved). */
|
||||
get scanLaunchCount(): number {
|
||||
return this.countRequests("POST", "/scans");
|
||||
/** How many organization bulk scan operations were launched. */
|
||||
get organizationBulkScanCallCount(): number {
|
||||
return this.countRequests("POST", "/scans/bulk");
|
||||
}
|
||||
|
||||
// --- Mount + environment ------------------------------------------------
|
||||
|
||||
@@ -14,9 +14,6 @@ const AWS_ROLE_ARN = "arn:aws:iam::111111111111:role/ProwlerScan";
|
||||
/** The organization id seeded by `awsHierarchyFixture`. */
|
||||
const AWS_HIERARCHY_ORG_ID = "org-aws-1";
|
||||
const AWS_ORG_NAME = "My AWS Organization";
|
||||
/** Providers `awsOnboardingFixture`'s apply creates (one per ready account). */
|
||||
const CREATED_PROVIDER_COUNT = 2;
|
||||
|
||||
/** A world where one of the two ready accounts fails its connection test. */
|
||||
const partialConnectionFixture = (): OrgFixture =>
|
||||
awsOnboardingFixture({
|
||||
@@ -57,7 +54,7 @@ describe("AWS Organizations onboarding (baseline)", () => {
|
||||
await harness.waitForLaunchComplete();
|
||||
|
||||
expect(harness.scheduleBulkCallCount).toBe(1);
|
||||
expect(harness.scanLaunchCount).toBe(CREATED_PROVIDER_COUNT);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 60000);
|
||||
|
||||
it("disables blocked accounts and excludes them from the selectable count", async () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
AWS and GCP organization onboarding launches all linked provider scans through one bulk operation
|
||||
@@ -20,17 +20,20 @@ import { OrgLaunchScan } from "./org-launch-scan";
|
||||
const {
|
||||
launchOrganizationScansMock,
|
||||
pushMock,
|
||||
scheduleOrganizationDailyScansMock,
|
||||
toastMock,
|
||||
updateSchedulesBulkMock,
|
||||
} = vi.hoisted(() => ({
|
||||
launchOrganizationScansMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
scheduleOrganizationDailyScansMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
updateSchedulesBulkMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans/scans", () => ({
|
||||
launchOrganizationScans: launchOrganizationScansMock,
|
||||
scheduleOrganizationDailyScans: scheduleOrganizationDailyScansMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/schedules/schedules", () => ({
|
||||
@@ -67,9 +70,16 @@ describe("OrgLaunchScan", () => {
|
||||
localStorage.clear();
|
||||
launchOrganizationScansMock.mockReset();
|
||||
pushMock.mockReset();
|
||||
scheduleOrganizationDailyScansMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
updateSchedulesBulkMock.mockReset();
|
||||
launchOrganizationScansMock.mockResolvedValue({
|
||||
data: [
|
||||
{ type: "scans", id: "scan-1" },
|
||||
{ type: "scans", id: "scan-2" },
|
||||
],
|
||||
});
|
||||
scheduleOrganizationDailyScansMock.mockResolvedValue({
|
||||
successCount: 2,
|
||||
failureCount: 0,
|
||||
totalCount: 2,
|
||||
@@ -128,7 +138,7 @@ describe("OrgLaunchScan", () => {
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
|
||||
});
|
||||
|
||||
it("should launch initial scans only for updated providers", async () => {
|
||||
it("should launch the complete organization after a partial schedule save", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onFooterChange = vi.fn();
|
||||
@@ -162,10 +172,8 @@ describe("OrgLaunchScan", () => {
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
["provider-2"],
|
||||
"single",
|
||||
);
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1");
|
||||
expect(scheduleOrganizationDailyScansMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
|
||||
@@ -348,10 +356,7 @@ describe("OrgLaunchScan", () => {
|
||||
|
||||
// Then — every created provider is treated as saved and scanned.
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
"single",
|
||||
),
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1"),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -387,12 +392,12 @@ describe("OrgLaunchScan", () => {
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
expect(scheduleOrganizationDailyScansMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
"daily",
|
||||
),
|
||||
);
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
|
||||
@@ -426,12 +431,14 @@ describe("OrgLaunchScan", () => {
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
"single",
|
||||
),
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1"),
|
||||
);
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Single scan launched for 2 accounts.",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
|
||||
|
||||
@@ -6,7 +6,10 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { launchOrganizationScans } from "@/actions/scans/scans";
|
||||
import {
|
||||
launchOrganizationScans,
|
||||
scheduleOrganizationDailyScans,
|
||||
} from "@/actions/scans/scans";
|
||||
import { updateSchedulesBulk } from "@/actions/schedules/schedules";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
@@ -93,6 +96,7 @@ export function OrgLaunchScan({
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
organizationId,
|
||||
organizationExternalId,
|
||||
organizationType,
|
||||
createdProviderIds,
|
||||
@@ -198,14 +202,19 @@ export function OrgLaunchScan({
|
||||
|
||||
let initialScanFailureCount = 0;
|
||||
let initialScanSuccessCount = 0;
|
||||
let initialScanError: string | undefined;
|
||||
|
||||
if (values.launchInitialScan) {
|
||||
const scanResult = await launchOrganizationScans(
|
||||
updatedProviderIds,
|
||||
SCAN_SCHEDULE.SINGLE,
|
||||
);
|
||||
initialScanFailureCount = scanResult.failureCount;
|
||||
initialScanSuccessCount = scanResult.successCount;
|
||||
const scanResult = organizationId
|
||||
? await launchOrganizationScans(organizationId)
|
||||
: { error: "Organization ID is required" };
|
||||
|
||||
if ("error" in scanResult) {
|
||||
initialScanFailureCount = updatedProviderIds.length;
|
||||
initialScanError = getActionErrorMessage(scanResult);
|
||||
} else {
|
||||
initialScanSuccessCount = scanResult.data.length;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLaunching(false);
|
||||
@@ -228,7 +237,9 @@ export function OrgLaunchScan({
|
||||
: "Scan schedules saved",
|
||||
description:
|
||||
initialScanFailureCount > 0
|
||||
? `${description} Initial scans failed for ${formatCandidateCount(initialScanFailureCount, noun)}.`
|
||||
? initialScanError
|
||||
? `${description} The initial organization scan could not be launched: ${initialScanError}`
|
||||
: `${description} Initial scans failed for ${formatCandidateCount(initialScanFailureCount, noun)}.`
|
||||
: description,
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
@@ -245,11 +256,28 @@ export function OrgLaunchScan({
|
||||
|
||||
setIsLaunching(true);
|
||||
|
||||
const result = await launchOrganizationScans(
|
||||
createdProviderIds,
|
||||
effectiveScheduleOption,
|
||||
);
|
||||
const successCount = result.successCount;
|
||||
let successCount = 0;
|
||||
|
||||
if (effectiveScheduleOption === SCAN_SCHEDULE.SINGLE) {
|
||||
const result = organizationId
|
||||
? await launchOrganizationScans(organizationId)
|
||||
: { error: "Organization ID is required" };
|
||||
|
||||
if ("error" in result) {
|
||||
setIsLaunching(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to launch organization scan",
|
||||
description: getActionErrorMessage(result),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
successCount = result.data.length;
|
||||
} else {
|
||||
const result = await scheduleOrganizationDailyScans(createdProviderIds);
|
||||
successCount = result.successCount;
|
||||
}
|
||||
const targetTab =
|
||||
effectiveScheduleOption === SCAN_SCHEDULE.SINGLE
|
||||
? SCAN_JOBS_TAB.ACTIVE
|
||||
|
||||
Reference in New Issue
Block a user