From 058db7bcc97848c131c9bebd92137aae41db89ba Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Thu, 6 Aug 2026 09:48:53 +0200 Subject: [PATCH] fix(ui): launch organization scans through the bulk endpoint (#12350) --- ui/__tests__/msw/handlers/organizations.ts | 17 +++- ui/actions/scans/scans.test.ts | 85 ++++++++++++++++- ui/actions/scans/scans.ts | 91 +++++++++++++++++-- .../providers-page.gcp.integration.test.tsx | 8 +- .../providers/providers-page.harness.tsx | 6 +- .../providers-page.integration.test.tsx | 5 +- .../organization-bulk-scan.fixed.md | 1 + .../organizations/org-launch-scan.test.tsx | 37 +++++--- .../organizations/org-launch-scan.tsx | 54 ++++++++--- 9 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 ui/changelog.d/organization-bulk-scan.fixed.md diff --git a/ui/__tests__/msw/handlers/organizations.ts b/ui/__tests__/msw/handlers/organizations.ts index dd6b1907ba..fcab07f0ff 100644 --- a/ui/__tests__/msw/handlers/organizations.ts +++ b/ui/__tests__/msw/handlers/organizations.ts @@ -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 }, ), ), diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index a7f7fd0c7e..bf91484ee7 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -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); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index e192bb5b49..89f832552c 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -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 => { + 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, diff --git a/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx index 5be4b7c81a..e9f97a35ed 100644 --- a/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx @@ -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 () => { diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 4820f0ef21..93f885cd15 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -98,9 +98,9 @@ export class ProvidersPageHarness extends BrowserHarness { 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 ------------------------------------------------ diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index cde5af5dd0..f5d8d7c0d5 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -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 () => { diff --git a/ui/changelog.d/organization-bulk-scan.fixed.md b/ui/changelog.d/organization-bulk-scan.fixed.md new file mode 100644 index 0000000000..547f595d76 --- /dev/null +++ b/ui/changelog.d/organization-bulk-scan.fixed.md @@ -0,0 +1 @@ +AWS and GCP organization onboarding launches all linked provider scans through one bulk operation diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index 7459dded4a..927d8b8cef 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -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}`); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index ff25a8fa84..06fa2435d5 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -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: ( @@ -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