fix(ui): launch organization scans through the bulk endpoint (#12350)

This commit is contained in:
Josema Camacho
2026-08-06 09:48:53 +02:00
committed by GitHub
parent d1a37039fd
commit 058db7bcc9
9 changed files with 253 additions and 51 deletions
+15 -2
View File
@@ -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 },
),
),
+82 -3
View File
@@ -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);
+84 -7
View File
@@ -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