mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): add organization bulk scan scheduling (#11643)
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SCHEDULE_FREQUENCY } from "@/types/schedules";
|
||||
import {
|
||||
SCHEDULE_FREQUENCY,
|
||||
type ScheduleUpdatePayload,
|
||||
} from "@/types/schedules";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
@@ -30,9 +33,15 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { getSchedule, removeSchedule, updateSchedule } from "./schedules";
|
||||
import {
|
||||
getSchedule,
|
||||
removeSchedule,
|
||||
updateSchedule,
|
||||
updateSchedulesBulk,
|
||||
} from "./schedules";
|
||||
|
||||
const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc";
|
||||
const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a";
|
||||
|
||||
const payload = {
|
||||
scan_enabled: true,
|
||||
@@ -61,6 +70,108 @@ describe("schedule write actions revalidate only on success", () => {
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
|
||||
});
|
||||
|
||||
it("posts the JSON:API bulk schedule payload", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: [PROVIDER_ID, SECOND_PROVIDER_ID],
|
||||
failed: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/schedules/bulk",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
schedule: payload,
|
||||
provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid bulk provider ids without issuing a request", async () => {
|
||||
expect(
|
||||
await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload),
|
||||
).toEqual({
|
||||
error: "Invalid provider ids.",
|
||||
});
|
||||
expect(await updateSchedulesBulk([], payload)).toEqual({
|
||||
error: "Invalid provider ids.",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid bulk schedule payloads without issuing a request", async () => {
|
||||
const invalidPayload = {
|
||||
...payload,
|
||||
scan_hour: "12",
|
||||
} as unknown as ScheduleUpdatePayload;
|
||||
|
||||
expect(
|
||||
await updateSchedulesBulk(
|
||||
[PROVIDER_ID, SECOND_PROVIDER_ID],
|
||||
invalidPayload,
|
||||
),
|
||||
).toEqual({
|
||||
error: "Invalid schedule payload.",
|
||||
});
|
||||
expect(getAuthHeadersMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes bulk auth header errors", async () => {
|
||||
const authError = new Error("Session expired");
|
||||
getAuthHeadersMock.mockRejectedValue(authError);
|
||||
|
||||
expect(
|
||||
await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload),
|
||||
).toEqual({ error: "Failed" });
|
||||
expect(handleApiErrorMock).toHaveBeenCalledWith(authError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates /scans and /providers after a partial bulk success", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: [PROVIDER_ID],
|
||||
failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await updateSchedulesBulk(
|
||||
[PROVIDER_ID, SECOND_PROVIDER_ID],
|
||||
payload,
|
||||
);
|
||||
|
||||
expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]);
|
||||
expect(result.data?.attributes?.failed).toHaveLength(1);
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
|
||||
});
|
||||
|
||||
it("does not revalidate when the bulk update returns an error result", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" });
|
||||
|
||||
await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload);
|
||||
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not revalidate when the update returns an error result", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" });
|
||||
|
||||
|
||||
@@ -4,8 +4,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { scheduleUpdatePayloadSchema } from "@/lib/schedules";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules";
|
||||
import type {
|
||||
ScheduleProps,
|
||||
SchedulesBulkResponse,
|
||||
ScheduleUpdatePayload,
|
||||
} from "@/types/schedules";
|
||||
|
||||
// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass.
|
||||
const providerIdSchema = z.uuid();
|
||||
@@ -15,6 +20,13 @@ function parseProviderId(providerId: string): string | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseProviderIds(providerIds: string[]): string[] | null {
|
||||
if (providerIds.length === 0) return null;
|
||||
|
||||
const ids = providerIds.map(parseProviderId);
|
||||
return ids.every((id): id is string => id !== null) ? ids : null;
|
||||
}
|
||||
|
||||
function revalidateScheduleViews() {
|
||||
revalidatePath("/scans");
|
||||
revalidatePath("/providers");
|
||||
@@ -104,6 +116,45 @@ export const updateSchedule = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSchedulesBulk = async (
|
||||
providerIds: string[],
|
||||
payload: ScheduleUpdatePayload,
|
||||
): Promise<SchedulesBulkResponse> => {
|
||||
const ids = parseProviderIds(providerIds);
|
||||
if (!ids) return { error: "Invalid provider ids." };
|
||||
|
||||
const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload);
|
||||
if (!parsedPayload.success) return { error: "Invalid schedule payload." };
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/schedules/bulk`);
|
||||
const body = {
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
schedule: parsedPayload.data,
|
||||
provider_ids: ids,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = (await handleApiResponse(response)) as SchedulesBulkResponse;
|
||||
if (!result?.error) {
|
||||
revalidateScheduleViews();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeSchedule = async (providerId: string) => {
|
||||
const id = parseProviderId(providerId);
|
||||
if (!id) return { error: "Invalid provider id." };
|
||||
|
||||
@@ -28,7 +28,10 @@ vi.mock("@/actions/schedules", () => schedulesActionsMock);
|
||||
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import { ProvidersApiResponse } from "@/types/providers";
|
||||
import { ProvidersProviderRow } from "@/types/providers-table";
|
||||
import {
|
||||
isProvidersOrganizationRow,
|
||||
ProvidersProviderRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCHEDULE_FREQUENCY,
|
||||
type ScheduleAttributes,
|
||||
@@ -643,13 +646,73 @@ describe("buildProvidersTableRows", () => {
|
||||
|
||||
// Then
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION);
|
||||
expect(rows[0].subRows).toHaveLength(2);
|
||||
const orgRow = rows[0];
|
||||
expect(isProvidersOrganizationRow(orgRow)).toBe(true);
|
||||
if (!isProvidersOrganizationRow(orgRow)) {
|
||||
throw new Error("Expected organization row");
|
||||
}
|
||||
expect(orgRow.subRows).toHaveLength(2);
|
||||
expect(
|
||||
rows[0].subRows?.every(
|
||||
orgRow.subRows?.every(
|
||||
(row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(orgRow.providerIds).toEqual(["provider-1", "provider-2"]);
|
||||
});
|
||||
|
||||
it("keeps organization relationship provider ids even when providers are not in the visible page", () => {
|
||||
// Given
|
||||
const providers = [
|
||||
toProviderRow(providersResponse.data[0], {
|
||||
relationships: {
|
||||
...providersResponse.data[0].relationships,
|
||||
organization: {
|
||||
data: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// When
|
||||
const rows = buildProvidersTableRows({
|
||||
providers,
|
||||
organizations: [
|
||||
{
|
||||
id: "org-1",
|
||||
type: "organizations",
|
||||
attributes: {
|
||||
name: "Large Organization",
|
||||
org_type: "aws",
|
||||
external_id: "o-large",
|
||||
metadata: {},
|
||||
root_external_id: "r-large",
|
||||
},
|
||||
relationships: {
|
||||
providers: {
|
||||
data: [
|
||||
{ type: "providers", id: "provider-1" },
|
||||
{ type: "providers", id: "provider-not-in-page" },
|
||||
],
|
||||
},
|
||||
organizational_units: {
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
organizationUnits: [],
|
||||
isCloud: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(rows).toHaveLength(1);
|
||||
const orgRow = rows[0];
|
||||
expect(isProvidersOrganizationRow(orgRow)).toBe(true);
|
||||
if (!isProvidersOrganizationRow(orgRow)) {
|
||||
throw new Error("Expected organization row");
|
||||
}
|
||||
expect(orgRow.subRows).toHaveLength(1);
|
||||
expect(orgRow.providerIds).toEqual(["provider-1", "provider-not-in-page"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ const createOrganizationRow = ({
|
||||
externalId,
|
||||
organizationId,
|
||||
parentExternalId,
|
||||
providerIds,
|
||||
subRows,
|
||||
}: {
|
||||
externalId: string | null;
|
||||
@@ -191,6 +192,7 @@ const createOrganizationRow = ({
|
||||
name: string;
|
||||
organizationId: string | null;
|
||||
parentExternalId: string | null;
|
||||
providerIds: string[];
|
||||
subRows: ProvidersTableRow[];
|
||||
}): ProvidersOrganizationRow => ({
|
||||
id,
|
||||
@@ -200,7 +202,8 @@ const createOrganizationRow = ({
|
||||
externalId,
|
||||
organizationId,
|
||||
parentExternalId,
|
||||
providerCount: countProviderRows(subRows),
|
||||
providerCount: providerIds.length,
|
||||
providerIds,
|
||||
subRows,
|
||||
});
|
||||
|
||||
@@ -234,14 +237,14 @@ function getProviderRowsByIds({
|
||||
.filter((provider): provider is ProvidersProviderRow => Boolean(provider));
|
||||
}
|
||||
|
||||
function countProviderRows(rows: ProvidersTableRow[]): number {
|
||||
return rows.reduce((total, row) => {
|
||||
if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
|
||||
return total + 1;
|
||||
}
|
||||
function dedupeIds(ids: string[]): string[] {
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
return total + countProviderRows(row.subRows);
|
||||
}, 0);
|
||||
function collectOrganizationRowProviderIds(
|
||||
rows: ProvidersOrganizationRow[],
|
||||
): string[] {
|
||||
return dedupeIds(rows.flatMap((row) => row.providerIds));
|
||||
}
|
||||
|
||||
function getOrganizationUnitRelationshipId(
|
||||
@@ -308,6 +311,13 @@ function buildOrganizationUnitRows({
|
||||
? providerRowsFromRelationships
|
||||
: (providersByOrganizationUnitId.get(organizationUnit.id) ?? []);
|
||||
const subRows = [...childOrganizationUnitRows, ...providerRows];
|
||||
const directProviderIds =
|
||||
providerRowsFromRelationships.length > 0
|
||||
? getRelationshipProviderIds(organizationUnit.relationships)
|
||||
: providerRows.map((provider) => provider.id);
|
||||
const childProviderIds = collectOrganizationRowProviderIds(
|
||||
childOrganizationUnitRows,
|
||||
);
|
||||
|
||||
return createOrganizationRow({
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
|
||||
@@ -316,10 +326,13 @@ function buildOrganizationUnitRows({
|
||||
externalId: organizationUnit.attributes.external_id,
|
||||
organizationId,
|
||||
parentExternalId: organizationUnit.attributes.parent_external_id,
|
||||
providerIds: dedupeIds([...childProviderIds, ...directProviderIds]),
|
||||
subRows,
|
||||
});
|
||||
})
|
||||
.filter((organizationUnitRow) => organizationUnitRow.subRows.length > 0);
|
||||
.filter(
|
||||
(organizationUnitRow) => organizationUnitRow.providerIds.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProvidersTableRows({
|
||||
@@ -418,6 +431,12 @@ export function buildProvidersTableRows({
|
||||
(provider) => !providersInOus.has(provider.id),
|
||||
);
|
||||
const subRows = [...organizationProviders, ...organizationUnitRows];
|
||||
const directProviderIds =
|
||||
organizationProvidersFromRelationships.length > 0
|
||||
? getRelationshipProviderIds(organization.relationships)
|
||||
: organizationProviders.map((provider) => provider.id);
|
||||
const organizationUnitProviderIds =
|
||||
collectOrganizationRowProviderIds(organizationUnitRows);
|
||||
|
||||
return createOrganizationRow({
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
|
||||
@@ -426,10 +445,14 @@ export function buildProvidersTableRows({
|
||||
externalId: organization.attributes.external_id,
|
||||
organizationId: organization.id,
|
||||
parentExternalId: organization.attributes.root_external_id,
|
||||
providerIds: dedupeIds([
|
||||
...directProviderIds,
|
||||
...organizationUnitProviderIds,
|
||||
]),
|
||||
subRows,
|
||||
});
|
||||
})
|
||||
.filter((organizationRow) => organizationRow.subRows.length > 0);
|
||||
.filter((organizationRow) => organizationRow.providerIds.length > 0);
|
||||
|
||||
const assignedProviderIds = new Set<string>();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ScanProps,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
|
||||
// Pending schedule rows are derived from provider schedules, but must honor the
|
||||
@@ -244,9 +245,11 @@ export default async function Scans({
|
||||
const SSRDataTableScans = async ({
|
||||
searchParams,
|
||||
providers,
|
||||
scanScheduleCapability,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
providers: ProviderProps[];
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
}) => {
|
||||
const tab = getScanJobsTab(searchParams.tab);
|
||||
|
||||
@@ -359,6 +362,7 @@ const SSRDataTableScans = async ({
|
||||
meta={tableMeta}
|
||||
tab={tab}
|
||||
hasFilters={hasUserFilters}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
import {
|
||||
SCAN_JOBS_TAB,
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
SCHEDULE_FREQUENCY,
|
||||
} from "@/types";
|
||||
|
||||
import { OrgLaunchScan } from "./org-launch-scan";
|
||||
|
||||
const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
launchOrganizationScansMock,
|
||||
pushMock,
|
||||
toastMock,
|
||||
updateSchedulesBulkMock,
|
||||
} = vi.hoisted(() => ({
|
||||
launchOrganizationScansMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
updateSchedulesBulkMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans/scans", () => ({
|
||||
launchOrganizationScans: launchOrganizationScansMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/schedules/schedules", () => ({
|
||||
updateSchedulesBulk: updateSchedulesBulkMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
@@ -32,117 +47,381 @@ vi.mock("@/components/ui", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const PROVIDER_IDS = ["provider-1", "provider-2"];
|
||||
|
||||
const lastFooterConfig = (onFooterChange: ReturnType<typeof vi.fn>) =>
|
||||
onFooterChange.mock.calls.at(-1)?.[0];
|
||||
|
||||
describe("OrgLaunchScan", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
|
||||
resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
|
||||
} as Intl.DateTimeFormat);
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
launchOrganizationScansMock.mockReset();
|
||||
pushMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
updateSchedulesBulkMock.mockReset();
|
||||
launchOrganizationScansMock.mockResolvedValue({
|
||||
successCount: 2,
|
||||
failureCount: 0,
|
||||
totalCount: 2,
|
||||
errors: [],
|
||||
});
|
||||
updateSchedulesBulkMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: PROVIDER_IDS,
|
||||
failed: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
useOrgSetupStore.getState().reset();
|
||||
useOrgSetupStore
|
||||
.getState()
|
||||
.setOrganization("org-1", "My Organization", "o-abc123def4");
|
||||
useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]);
|
||||
useOrgSetupStore.getState().setCreatedProviderIds(PROVIDER_IDS);
|
||||
});
|
||||
|
||||
it("shows a success toast with an action linking to scans", async () => {
|
||||
// Given
|
||||
launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
|
||||
const onFooterChange = vi.fn();
|
||||
describe("when capability is ADVANCED", () => {
|
||||
it("should save schedules through the bulk endpoint", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
});
|
||||
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
|
||||
await act(async () => {
|
||||
footerConfig.onAction?.();
|
||||
// When
|
||||
await screen.findByText("Scan Schedule");
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
expect.objectContaining({
|
||||
scan_enabled: true,
|
||||
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
scan_hour: expect.any(Number),
|
||||
scan_timezone: "Europe/Madrid",
|
||||
}),
|
||||
);
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
expect(pushMock).toHaveBeenCalledWith("/providers");
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
it("should launch initial scans only for updated providers", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onFooterChange = vi.fn();
|
||||
updateSchedulesBulkMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: ["provider-2"],
|
||||
failed: [{ id: "provider-1", error: "Denied" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
await screen.findByRole("checkbox", {
|
||||
name: /launch an initial scan now/i,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
["provider-2"],
|
||||
"single",
|
||||
);
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
|
||||
});
|
||||
|
||||
it("should disable launch actions while schedule capability is loading", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
isScheduleCapabilityLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.findByText("Loading scan options...");
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true);
|
||||
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should surface an error toast and stay on the wizard when the bulk update fails", async () => {
|
||||
// Given
|
||||
const onClose = vi.fn();
|
||||
const onFooterChange = vi.fn();
|
||||
updateSchedulesBulkMock.mockResolvedValue({ error: "Denied" });
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={onClose}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.findByText("Scan Schedule");
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
title: "Unable to save scan schedules",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should treat a fully-failed bulk response as an error without navigating away", async () => {
|
||||
// Given
|
||||
const onClose = vi.fn();
|
||||
const onFooterChange = vi.fn();
|
||||
updateSchedulesBulkMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: [],
|
||||
failed: [
|
||||
{ id: "provider-1", error: "Denied" },
|
||||
{ id: "provider-2", error: "Denied" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={onClose}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.findByText("Scan Schedule");
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
title: "Unable to save scan schedules",
|
||||
description: "The scan schedule could not be saved for 2 accounts.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should describe partial failures in the success toast", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
updateSchedulesBulkMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules-bulk",
|
||||
attributes: {
|
||||
updated: ["provider-2"],
|
||||
failed: [{ provider_id: "provider-1", error: "Denied" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.findByText("Scan Schedule");
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Scan schedules saved",
|
||||
description:
|
||||
"The schedule was saved for 1 account, but 1 account could not be updated.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
const toastPayload = toastMock.mock.calls[0]?.[0];
|
||||
expect(toastPayload.title).toBe("Scan Launched");
|
||||
expect(toastPayload.action).toBeDefined();
|
||||
expect(toastPayload.action.props.children.props.href).toBe("/scans");
|
||||
});
|
||||
|
||||
it("uses a single manual scan when schedules are unavailable", async () => {
|
||||
// Given
|
||||
launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
|
||||
const onFooterChange = vi.fn();
|
||||
describe("when capability is DAILY_LEGACY", () => {
|
||||
it("should keep the legacy daily scheduling path", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText(/scheduled scans are not available for trial accounts/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
// When
|
||||
await screen.findByText(
|
||||
"Select a Prowler scan schedule for these accounts.",
|
||||
);
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// When
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
"daily",
|
||||
),
|
||||
);
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
|
||||
});
|
||||
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
|
||||
await act(async () => {
|
||||
footerConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
["provider-1"],
|
||||
"single",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks manual scans when the trial scan limit is reached", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
describe("when capability is MANUAL_ONLY", () => {
|
||||
it("should launch single scans without rendering schedule controls", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
isScanLimitReached
|
||||
/>,
|
||||
);
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
});
|
||||
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
|
||||
await act(async () => {
|
||||
footerConfig.onAction?.();
|
||||
// When
|
||||
expect(
|
||||
screen.getByText(
|
||||
/scheduled scans are not available for trial accounts/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
|
||||
PROVIDER_IDS,
|
||||
"single",
|
||||
),
|
||||
);
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
|
||||
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
|
||||
expect(footerConfig.actionDisabled).toBe(true);
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
describe("when capability is BLOCKED", () => {
|
||||
it("should disable the action without calling scans or schedules", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await waitFor(() => {
|
||||
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
|
||||
});
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
|
||||
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
|
||||
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
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 { updateSchedulesBulk } from "@/actions/schedules/schedules";
|
||||
import { AWSProviderBadge } from "@/components/icons/providers-badge";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "@/components/providers/wizard/steps/footer-controls";
|
||||
import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,11 +24,21 @@ import {
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import {
|
||||
buildScheduleUpdatePayload,
|
||||
getScanScheduleCapability,
|
||||
getScheduleFormDefaults,
|
||||
scheduleFormSchema,
|
||||
} from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import {
|
||||
SCAN_JOBS_TAB,
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
} from "@/types/schedules";
|
||||
type ScheduleFormValues,
|
||||
type SchedulesBulkResponse,
|
||||
} from "@/types";
|
||||
import { TREE_ITEM_STATUS } from "@/types/tree";
|
||||
|
||||
interface OrgLaunchScanProps {
|
||||
@@ -32,12 +46,17 @@ interface OrgLaunchScanProps {
|
||||
onBack: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
/**
|
||||
* Schedule capability override. Prowler Cloud passes MANUAL_ONLY for trial
|
||||
* tenants so organization onboarding cannot create recurring schedules.
|
||||
* Schedule capability override. Prowler Cloud passes MANUAL_ONLY/BLOCKED for
|
||||
* billing-limited tenants; OSS falls back to an environment-based capability.
|
||||
*/
|
||||
capability?: ScanScheduleCapability;
|
||||
/** Cloud-only manual scan quota signal. */
|
||||
isScanLimitReached?: boolean;
|
||||
/**
|
||||
* Cloud-only loading state while billing is resolved into a schedule
|
||||
* capability. OSS leaves it false.
|
||||
*/
|
||||
isScheduleCapabilityLoading?: boolean;
|
||||
}
|
||||
|
||||
const SCAN_SCHEDULE = {
|
||||
@@ -47,30 +66,182 @@ const SCAN_SCHEDULE = {
|
||||
|
||||
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
|
||||
|
||||
function getStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose schedule was actually saved. The backend reports successes
|
||||
* under `updated`, populated only after each provider's schedule commits, so
|
||||
* it already excludes failures — no client-side subtraction is needed.
|
||||
*/
|
||||
function getUpdatedProviderIds(result: SchedulesBulkResponse): string[] {
|
||||
return getStringArray(result.data?.attributes?.updated);
|
||||
}
|
||||
|
||||
function getFailedCount(result: SchedulesBulkResponse): number {
|
||||
const failed = result.data?.attributes?.failed;
|
||||
return Array.isArray(failed) ? failed.length : 0;
|
||||
}
|
||||
|
||||
function formatAccountCount(count: number): string {
|
||||
return `${count} account${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function getScansHref(tab: (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]) {
|
||||
return `/scans?tab=${tab}`;
|
||||
}
|
||||
|
||||
export function OrgLaunchScan({
|
||||
onClose,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
capability,
|
||||
isScanLimitReached = false,
|
||||
isScheduleCapabilityLoading = false,
|
||||
}: OrgLaunchScanProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { organizationExternalId, createdProviderIds, reset } =
|
||||
useOrgSetupStore();
|
||||
|
||||
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
|
||||
const isAdvanced = resolvedCapability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
||||
const isDailyLegacy =
|
||||
resolvedCapability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
|
||||
const isManualOnly =
|
||||
resolvedCapability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
const isBlocked =
|
||||
resolvedCapability === SCAN_SCHEDULE_CAPABILITY.BLOCKED ||
|
||||
(isManualOnly && isScanLimitReached);
|
||||
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
|
||||
SCAN_SCHEDULE.DAILY,
|
||||
);
|
||||
const form = useForm<ScheduleFormValues>({
|
||||
resolver: zodResolver(scheduleFormSchema),
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
const launchInitialScan = useWatch({
|
||||
control: form.control,
|
||||
name: "launchInitialScan",
|
||||
});
|
||||
const launchActionRef = useRef<() => void>(() => {});
|
||||
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
|
||||
const effectiveScheduleOption = isManualOnly
|
||||
? SCAN_SCHEDULE.SINGLE
|
||||
: scheduleOption;
|
||||
const actionDisabled =
|
||||
isLaunching ||
|
||||
isScheduleCapabilityLoading ||
|
||||
isBlocked ||
|
||||
createdProviderIds.length === 0;
|
||||
const actionLabel = isAdvanced
|
||||
? isLaunching
|
||||
? launchInitialScan
|
||||
? "Saving and launching..."
|
||||
: "Saving..."
|
||||
: launchInitialScan
|
||||
? "Save and launch scan"
|
||||
: "Save"
|
||||
: isLaunching
|
||||
? "Launching scans..."
|
||||
: "Launch scan";
|
||||
|
||||
const handleLaunchScan = async () => {
|
||||
if (isManualOnly && isScanLimitReached) {
|
||||
const finishSuccess = () => {
|
||||
reset();
|
||||
onClose();
|
||||
router.push("/providers");
|
||||
};
|
||||
|
||||
const handleAdvancedSchedule = form.handleSubmit(async (values) => {
|
||||
if (actionDisabled || !isAdvanced) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLaunching(true);
|
||||
|
||||
const result = await updateSchedulesBulk(
|
||||
createdProviderIds,
|
||||
buildScheduleUpdatePayload(values),
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
setIsLaunching(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to save scan schedules",
|
||||
description: String(result.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProviderIds = getUpdatedProviderIds(result);
|
||||
const failedCount = getFailedCount(result);
|
||||
|
||||
// No provider was actually updated (e.g. the endpoint returned 200 but every
|
||||
// schedule failed). Surface it as an error and keep the wizard open to retry
|
||||
// instead of navigating away with a misleading "saved for 0 accounts" toast.
|
||||
if (updatedProviderIds.length === 0) {
|
||||
setIsLaunching(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to save scan schedules",
|
||||
description:
|
||||
failedCount > 0
|
||||
? `The scan schedule could not be saved for ${formatAccountCount(failedCount)}.`
|
||||
: "The scan schedule could not be saved for any account.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let initialScanFailureCount = 0;
|
||||
let initialScanSuccessCount = 0;
|
||||
|
||||
if (values.launchInitialScan) {
|
||||
const scanResult = await launchOrganizationScans(
|
||||
updatedProviderIds,
|
||||
SCAN_SCHEDULE.SINGLE,
|
||||
);
|
||||
initialScanFailureCount = scanResult.failureCount;
|
||||
initialScanSuccessCount = scanResult.successCount;
|
||||
}
|
||||
|
||||
setIsLaunching(false);
|
||||
finishSuccess();
|
||||
|
||||
const updatedCount = updatedProviderIds.length;
|
||||
const description =
|
||||
failedCount > 0
|
||||
? `The schedule was saved for ${formatAccountCount(updatedCount)}, but ${formatAccountCount(failedCount)} could not be updated.`
|
||||
: `The scan schedule was saved for ${formatAccountCount(updatedCount)}.`;
|
||||
const targetTab =
|
||||
initialScanSuccessCount > 0
|
||||
? SCAN_JOBS_TAB.ACTIVE
|
||||
: SCAN_JOBS_TAB.SCHEDULED;
|
||||
|
||||
toast({
|
||||
title:
|
||||
values.launchInitialScan && initialScanFailureCount === 0
|
||||
? "Scan schedules saved and initial scans launched"
|
||||
: "Scan schedules saved",
|
||||
description:
|
||||
initialScanFailureCount > 0
|
||||
? `${description} Initial scans failed for ${formatAccountCount(initialScanFailureCount)}.`
|
||||
: description,
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href={getScansHref(targetTab)}>Go to scans</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const handleLegacyLaunch = async () => {
|
||||
if (actionDisabled || isAdvanced) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,52 +252,58 @@ export function OrgLaunchScan({
|
||||
effectiveScheduleOption,
|
||||
);
|
||||
const successCount = result.successCount;
|
||||
const targetTab =
|
||||
effectiveScheduleOption === SCAN_SCHEDULE.SINGLE
|
||||
? SCAN_JOBS_TAB.ACTIVE
|
||||
: SCAN_JOBS_TAB.SCHEDULED;
|
||||
|
||||
setIsLaunching(false);
|
||||
reset();
|
||||
onClose();
|
||||
router.push("/providers");
|
||||
finishSuccess();
|
||||
|
||||
toast({
|
||||
title: "Scan Launched",
|
||||
description:
|
||||
effectiveScheduleOption === SCAN_SCHEDULE.DAILY
|
||||
? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.`
|
||||
: `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`,
|
||||
? `Daily scan scheduled for ${formatAccountCount(successCount)}.`
|
||||
: `Single scan launched for ${formatAccountCount(successCount)}.`,
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href="/scans">Go to scans</Link>
|
||||
<Link href={getScansHref(targetTab)}>Go to scans</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
launchActionRef.current = () => {
|
||||
void handleLaunchScan();
|
||||
if (isAdvanced) {
|
||||
void handleAdvancedSchedule();
|
||||
return;
|
||||
}
|
||||
void handleLegacyLaunch();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
backDisabled: isLaunching,
|
||||
backDisabled: isLaunching || isScheduleCapabilityLoading,
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: "Launch scan",
|
||||
actionDisabled:
|
||||
isLaunching ||
|
||||
createdProviderIds.length === 0 ||
|
||||
(isManualOnly && isScanLimitReached),
|
||||
actionLabel,
|
||||
actionDisabled,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: () => {
|
||||
launchActionRef.current();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
actionDisabled,
|
||||
actionLabel,
|
||||
createdProviderIds.length,
|
||||
isAdvanced,
|
||||
isLaunching,
|
||||
isManualOnly,
|
||||
isScanLimitReached,
|
||||
isScheduleCapabilityLoading,
|
||||
launchInitialScan,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
]);
|
||||
@@ -149,11 +326,17 @@ export function OrgLaunchScan({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLaunching ? (
|
||||
{isLaunching || isScheduleCapabilityLoading ? (
|
||||
<div className="flex min-h-[220px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
<p className="text-sm font-medium">
|
||||
{isScheduleCapabilityLoading
|
||||
? "Loading scan options..."
|
||||
: isAdvanced
|
||||
? "Saving scan schedules..."
|
||||
: "Launching scans..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -177,20 +360,26 @@ export function OrgLaunchScan({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isManualOnly ? (
|
||||
{isBlocked ? (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
) : isAdvanced ? (
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
disabled={isLaunching}
|
||||
showLaunchInitialScan
|
||||
showNextScheduledCopy
|
||||
/>
|
||||
) : isManualOnly ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for trial accounts. These
|
||||
accounts will run a one-time manual scan now.
|
||||
</p>
|
||||
{isScanLimitReached && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : isDailyLegacy ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Select a Prowler scan schedule for these accounts.
|
||||
@@ -215,7 +404,7 @@ export function OrgLaunchScan({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MetaDataProps } from "@/types";
|
||||
import {
|
||||
PROVIDERS_GROUP_KIND,
|
||||
PROVIDERS_ROW_TYPE,
|
||||
type ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
|
||||
dataTableMockState: {
|
||||
nextSelection: {} as Record<string, boolean>,
|
||||
},
|
||||
getColumnProvidersMock: vi.fn((..._args: unknown[]) => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: ({
|
||||
onRowSelectionChange,
|
||||
}: {
|
||||
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
|
||||
}) => (
|
||||
<div data-testid="providers-data-table">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowSelectionChange?.(dataTableMockState.nextSelection)}
|
||||
>
|
||||
Apply selection
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./table", () => ({
|
||||
getColumnProviders: (...args: unknown[]) => getColumnProvidersMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
computeSelectedScheduleProviders,
|
||||
ProvidersAccountsTable,
|
||||
} from "./providers-accounts-table";
|
||||
|
||||
const metadata: MetaDataProps = {
|
||||
pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] },
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
const createProviderRow = (
|
||||
id: string,
|
||||
uid = id,
|
||||
alias: string | null = id,
|
||||
): ProvidersTableRow =>
|
||||
({
|
||||
id,
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid,
|
||||
alias,
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { id: `secret-${id}`, type: "secrets" } },
|
||||
provider_groups: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
groupNames: [],
|
||||
hasSchedule: false,
|
||||
}) as ProvidersTableRow;
|
||||
|
||||
const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
|
||||
const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
|
||||
const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
|
||||
|
||||
const organizationRow: ProvidersTableRow = {
|
||||
id: "org-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
|
||||
name: "My AWS Organization",
|
||||
externalId: "o-abc123def4",
|
||||
parentExternalId: null,
|
||||
organizationId: "org-1",
|
||||
providerCount: 3,
|
||||
providerIds: ["provider-1", "provider-2", "provider-hidden"],
|
||||
subRows: [providerOne, providerTwo],
|
||||
};
|
||||
|
||||
const organizationalUnitRow: ProvidersTableRow = {
|
||||
id: "ou-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
|
||||
name: "Production OU",
|
||||
externalId: "ou-abc123",
|
||||
parentExternalId: "o-abc123def4",
|
||||
organizationId: "org-1",
|
||||
providerCount: 2,
|
||||
providerIds: ["provider-2", "provider-hidden-ou"],
|
||||
subRows: [providerTwo],
|
||||
};
|
||||
|
||||
describe("ProvidersAccountsTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataTableMockState.nextSelection = {};
|
||||
});
|
||||
|
||||
it("passes scan schedule capability to provider row action columns", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("providers-data-table")).toBeInTheDocument();
|
||||
expect(getColumnProvidersMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
);
|
||||
});
|
||||
|
||||
describe("schedule provider selection", () => {
|
||||
it("uses the selected provider id for provider rows", () => {
|
||||
// Given
|
||||
const rows = [providerOne, providerTwo];
|
||||
|
||||
// When
|
||||
const result = computeSelectedScheduleProviders(rows, { "0": true });
|
||||
|
||||
// Then
|
||||
expect(result.providerIds).toEqual(["provider-1"]);
|
||||
expect(result.providers.map((provider) => provider.providerId)).toEqual([
|
||||
"provider-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses every organization provider id when the organization is selected", () => {
|
||||
// Given
|
||||
const rows = [organizationRow];
|
||||
|
||||
// When
|
||||
const result = computeSelectedScheduleProviders(rows, { "0": true });
|
||||
|
||||
// Then
|
||||
expect(result.providerIds).toEqual([
|
||||
"provider-1",
|
||||
"provider-2",
|
||||
"provider-hidden",
|
||||
]);
|
||||
expect(result.providers.map((provider) => provider.providerId)).toEqual([
|
||||
"provider-1",
|
||||
"provider-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses every organizational unit provider id when the OU is selected", () => {
|
||||
// Given
|
||||
const rows = [
|
||||
{
|
||||
...organizationRow,
|
||||
subRows: [organizationalUnitRow],
|
||||
},
|
||||
];
|
||||
|
||||
// When
|
||||
const result = computeSelectedScheduleProviders(rows, { "0.0": true });
|
||||
|
||||
// Then
|
||||
expect(result.providerIds).toEqual(["provider-2", "provider-hidden-ou"]);
|
||||
});
|
||||
|
||||
it("deduplicates provider ids when an organization and child provider are selected", () => {
|
||||
// Given
|
||||
const rows = [organizationRow];
|
||||
|
||||
// When
|
||||
const result = computeSelectedScheduleProviders(rows, {
|
||||
"0": true,
|
||||
"0.0": true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.providerIds).toEqual([
|
||||
"provider-1",
|
||||
"provider-2",
|
||||
"provider-hidden",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses only selected child providers when an organization is partially selected", () => {
|
||||
// Given
|
||||
const rows = [organizationRow, providerThree];
|
||||
|
||||
// When
|
||||
const result = computeSelectedScheduleProviders(rows, {
|
||||
"0.1": true,
|
||||
"1": true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.providerIds).toEqual(["provider-2", "provider-3"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes selected provider ids to provider row action columns", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
dataTableMockState.nextSelection = { "0": true };
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[providerOne]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Apply selection" }));
|
||||
|
||||
// Then
|
||||
expect(getColumnProvidersMock).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
["provider-1"],
|
||||
["provider-1"],
|
||||
[
|
||||
expect.objectContaining({
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerUid: "111111111111",
|
||||
providerAlias: "Prod",
|
||||
}),
|
||||
],
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes selected organization provider ids and visible providers to provider row action columns", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
dataTableMockState.nextSelection = { "0": true };
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[organizationRow]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Apply selection" }));
|
||||
|
||||
// Then
|
||||
expect(getColumnProvidersMock).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
[],
|
||||
["provider-1", "provider-2", "provider-hidden"],
|
||||
[
|
||||
expect.objectContaining({ providerId: "provider-1" }),
|
||||
expect.objectContaining({ providerId: "provider-2" }),
|
||||
],
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RowSelectionState } from "@tanstack/react-table";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type {
|
||||
OrgWizardInitialData,
|
||||
@@ -11,8 +11,13 @@ import { DataTable } from "@/components/ui/table";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import {
|
||||
isProvidersOrganizationRow,
|
||||
isProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
} from "@/types/schedules";
|
||||
|
||||
import { getColumnProviders } from "./table";
|
||||
|
||||
@@ -20,6 +25,7 @@ interface ProvidersAccountsTableProps {
|
||||
isCloud: boolean;
|
||||
metadata?: MetaDataProps;
|
||||
rows: ProvidersTableRow[];
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
}
|
||||
@@ -50,31 +56,132 @@ function computeTestableProviderIds(
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function ProvidersAccountsTable({
|
||||
function toScanScheduleProvider(
|
||||
row: ProvidersTableRow,
|
||||
): ScanScheduleProvider | null {
|
||||
if (!isProvidersProviderRow(row)) return null;
|
||||
|
||||
return {
|
||||
providerId: row.id,
|
||||
providerType: row.attributes.provider,
|
||||
providerUid: row.attributes.uid,
|
||||
providerAlias: row.attributes.alias,
|
||||
};
|
||||
}
|
||||
|
||||
function appendUnique(target: string[], seen: Set<string>, ids: string[]) {
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
target.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
function appendUniqueProvider(
|
||||
target: ScanScheduleProvider[],
|
||||
seen: Set<string>,
|
||||
provider: ScanScheduleProvider | null,
|
||||
) {
|
||||
if (!provider || seen.has(provider.providerId)) return;
|
||||
seen.add(provider.providerId);
|
||||
target.push(provider);
|
||||
}
|
||||
|
||||
function collectVisibleScheduleProviders(rows: ProvidersTableRow[]) {
|
||||
const providers: ScanScheduleProvider[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function walk(items: ProvidersTableRow[]) {
|
||||
for (const item of items) {
|
||||
appendUniqueProvider(providers, seen, toScanScheduleProvider(item));
|
||||
if (isProvidersOrganizationRow(item)) {
|
||||
walk(item.subRows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(rows);
|
||||
return providers;
|
||||
}
|
||||
|
||||
export interface SelectedScheduleProvidersResult {
|
||||
providerIds: string[];
|
||||
providers: ScanScheduleProvider[];
|
||||
}
|
||||
|
||||
export function computeSelectedScheduleProviders(
|
||||
rows: ProvidersTableRow[],
|
||||
rowSelection: RowSelectionState,
|
||||
): SelectedScheduleProvidersResult {
|
||||
const providerIds: string[] = [];
|
||||
const providers: ScanScheduleProvider[] = [];
|
||||
const seenProviderIds = new Set<string>();
|
||||
const seenVisibleProviders = new Set<string>();
|
||||
|
||||
function walk(items: ProvidersTableRow[], prefix: string) {
|
||||
items.forEach((item, idx) => {
|
||||
const key = prefix ? `${prefix}.${idx}` : `${idx}`;
|
||||
const isSelected = rowSelection[key] === true;
|
||||
|
||||
if (isProvidersOrganizationRow(item)) {
|
||||
if (isSelected) {
|
||||
appendUnique(providerIds, seenProviderIds, item.providerIds);
|
||||
for (const provider of collectVisibleScheduleProviders(
|
||||
item.subRows,
|
||||
)) {
|
||||
appendUniqueProvider(providers, seenVisibleProviders, provider);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
walk(item.subRows, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
appendUnique(providerIds, seenProviderIds, [item.id]);
|
||||
appendUniqueProvider(
|
||||
providers,
|
||||
seenVisibleProviders,
|
||||
toScanScheduleProvider(item),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
walk(rows, "");
|
||||
|
||||
return { providerIds, providers };
|
||||
}
|
||||
|
||||
function ProvidersAccountsTableContent({
|
||||
isCloud,
|
||||
metadata,
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
}: ProvidersAccountsTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Reset selection when page changes
|
||||
const currentPage = metadata?.pagination?.page;
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [currentPage]);
|
||||
|
||||
const testableProviderIds = computeTestableProviderIds(rows, rowSelection);
|
||||
const selectedScheduleProviders = computeSelectedScheduleProviders(
|
||||
rows,
|
||||
rowSelection,
|
||||
);
|
||||
const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
|
||||
|
||||
const clearSelection = () => setRowSelection({});
|
||||
|
||||
const columns = getColumnProviders(
|
||||
rowSelection,
|
||||
testableProviderIds,
|
||||
selectedScheduleProviderIds,
|
||||
selectedScheduleProviders.providers,
|
||||
clearSelection,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanScheduleCapability,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -92,3 +199,9 @@ export function ProvidersAccountsTable({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProvidersAccountsTable(props: ProvidersAccountsTableProps) {
|
||||
const currentPage = props.metadata?.pagination?.page ?? "none";
|
||||
|
||||
return <ProvidersAccountsTableContent key={currentPage} {...props} />;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
const {
|
||||
providersAccountsTableSpy,
|
||||
refreshMock,
|
||||
replaceMock,
|
||||
searchParamsValue,
|
||||
} = vi.hoisted(() => ({
|
||||
providersAccountsTableSpy: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
@@ -49,7 +56,10 @@ vi.mock("@/components/providers/providers-filters", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
ProvidersAccountsTable: () => <div data-testid="providers-table">Table</div>,
|
||||
ProvidersAccountsTable: (props: { scanScheduleCapability?: string }) => {
|
||||
providersAccountsTableSpy(props);
|
||||
return <div data-testid="providers-table">Table</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
@@ -123,6 +133,7 @@ const disconnectedProviders: ProviderProps[] = [
|
||||
describe("ProvidersAccountsView", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
providersAccountsTableSpy.mockClear();
|
||||
searchParamsValue.current = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
@@ -285,6 +296,27 @@ describe("ProvidersAccountsView", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes scan schedule capability to provider row actions", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(providersAccountsTableSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanScheduleCapability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the normal Add Provider button", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -152,6 +152,7 @@ export function ProvidersAccountsView({
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
ProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
} from "@/types/schedules";
|
||||
|
||||
import { LinkToScans } from "../link-to-scans";
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
@@ -103,9 +107,12 @@ function countSelectedLeaves(rows: Row<ProvidersTableRow>[]): number {
|
||||
export function getColumnProviders(
|
||||
rowSelection: RowSelectionState,
|
||||
testableProviderIds: string[],
|
||||
selectedScheduleProviderIds: string[],
|
||||
selectedScheduleProviders: ScanScheduleProvider[],
|
||||
onClearSelection: () => void,
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
|
||||
scanScheduleCapability?: ScanScheduleCapability,
|
||||
): ColumnDef<ProvidersTableRow>[] {
|
||||
return [
|
||||
{
|
||||
@@ -317,9 +324,12 @@ export function getColumnProviders(
|
||||
hasSelection={hasSelection}
|
||||
isRowSelected={row.getIsSelected()}
|
||||
testableProviderIds={testableProviderIds}
|
||||
selectedScheduleProviderIds={selectedScheduleProviderIds}
|
||||
selectedScheduleProviders={selectedScheduleProviders}
|
||||
onClearSelection={onClearSelection}
|
||||
onOpenProviderWizard={onOpenProviderWizard}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
capability={scanScheduleCapability}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
|
||||
() => ({
|
||||
@@ -55,13 +56,16 @@ vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
|
||||
EditScanScheduleModal: ({
|
||||
open,
|
||||
provider,
|
||||
providers,
|
||||
}: {
|
||||
open: boolean;
|
||||
provider?: { providerId: string };
|
||||
providers?: { providerId: string }[];
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Edit Scan Schedule">
|
||||
Editing schedule for {provider?.providerId}
|
||||
Editing schedule for{" "}
|
||||
{providers ? `${providers.length} providers` : provider?.providerId}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
@@ -130,6 +134,7 @@ const createOrgRow = () =>
|
||||
parentExternalId: null,
|
||||
organizationId: "org-1",
|
||||
providerCount: 3,
|
||||
providerIds: ["provider-child-1", "provider-child-2"],
|
||||
subRows: [
|
||||
{
|
||||
id: "provider-child-1",
|
||||
@@ -162,6 +167,7 @@ const createOuRow = () =>
|
||||
parentExternalId: "o-abc123def4",
|
||||
organizationId: "org-1",
|
||||
providerCount: 2,
|
||||
providerIds: ["provider-ou-child-1"],
|
||||
subRows: [
|
||||
{
|
||||
id: "provider-ou-child-1",
|
||||
@@ -305,6 +311,56 @@ describe("DataTableRowActions", () => {
|
||||
).toHaveTextContent("Editing schedule for provider-1");
|
||||
});
|
||||
|
||||
it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Update Credentials for provider rows with credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -351,6 +407,29 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.getByText("Delete Organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Edit Scan Schedule for AWS organization rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOrgRow()}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(screen.getByText("Edit Scan Schedule"));
|
||||
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: /edit scan schedule/i }),
|
||||
).toHaveTextContent("Editing schedule for 2 providers");
|
||||
});
|
||||
|
||||
it("renders Delete Organization with destructive styling for org rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -436,6 +515,49 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows bulk Edit Scan Schedule next to Test Connection for selected rows", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOrgRow()}
|
||||
hasSelection={true}
|
||||
isRowSelected={true}
|
||||
testableProviderIds={["provider-child-1", "provider-standalone"]}
|
||||
selectedScheduleProviderIds={[
|
||||
"provider-child-1",
|
||||
"provider-child-2",
|
||||
"provider-standalone",
|
||||
]}
|
||||
selectedScheduleProviders={[
|
||||
{
|
||||
providerId: "provider-child-1",
|
||||
providerType: "aws",
|
||||
providerUid: "111",
|
||||
providerAlias: null,
|
||||
},
|
||||
{
|
||||
providerId: "provider-standalone",
|
||||
providerType: "aws",
|
||||
providerUid: "999",
|
||||
providerAlias: "Standalone",
|
||||
},
|
||||
]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Edit Scan Schedule (3)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Connection (2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
EDIT_SCAN_SCHEDULE_STATE,
|
||||
EditScanScheduleModal,
|
||||
type EditScanScheduleState,
|
||||
type ScanScheduleProvider,
|
||||
} from "@/components/scans/schedule/edit-scan-schedule-modal";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
type ScanScheduleProvider,
|
||||
type ScheduleApiResponse,
|
||||
} from "@/types/schedules";
|
||||
|
||||
@@ -64,6 +64,10 @@ interface DataTableRowActionsProps {
|
||||
isRowSelected: boolean;
|
||||
/** IDs of all selected providers that have credentials (testable) */
|
||||
testableProviderIds: string[];
|
||||
/** IDs of all selected providers that can receive schedule updates. */
|
||||
selectedScheduleProviderIds?: string[];
|
||||
/** Visible selected providers used as modal reference rows. */
|
||||
selectedScheduleProviders?: ScanScheduleProvider[];
|
||||
/** Callback to clear the row selection after bulk operation */
|
||||
onClearSelection: () => void;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
@@ -91,28 +95,56 @@ function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectChildScheduleProviders(
|
||||
rows: ProvidersTableRow[],
|
||||
): ScanScheduleProvider[] {
|
||||
const providers: ScanScheduleProvider[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
|
||||
providers.push({
|
||||
providerId: row.id,
|
||||
providerType: row.attributes.provider,
|
||||
providerUid: row.attributes.uid,
|
||||
providerAlias: row.attributes.alias,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
providers.push(...collectChildScheduleProviders(row.subRows));
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
interface OrgGroupDropdownActionsProps {
|
||||
rowData: ProvidersOrganizationRow;
|
||||
loading: boolean;
|
||||
canEditSchedule: boolean;
|
||||
hasSelection: boolean;
|
||||
testableProviderIds: string[];
|
||||
childTestableIds: string[];
|
||||
scheduleProviderCount: number;
|
||||
onClearSelection: () => void;
|
||||
onBulkTest: (ids: string[]) => Promise<void>;
|
||||
onTestChildConnections: () => Promise<void>;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
onOpenScheduleEditor: () => void;
|
||||
}
|
||||
|
||||
function OrgGroupDropdownActions({
|
||||
rowData,
|
||||
loading,
|
||||
canEditSchedule,
|
||||
hasSelection,
|
||||
testableProviderIds,
|
||||
childTestableIds,
|
||||
scheduleProviderCount,
|
||||
onClearSelection,
|
||||
onBulkTest,
|
||||
onTestChildConnections,
|
||||
onOpenOrganizationWizard,
|
||||
onOpenScheduleEditor,
|
||||
}: OrgGroupDropdownActionsProps) {
|
||||
const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false);
|
||||
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
|
||||
@@ -191,6 +223,14 @@ function OrgGroupDropdownActions({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isOrgKind && canEditSchedule && (
|
||||
<ActionDropdownItem
|
||||
icon={<CalendarClock />}
|
||||
label="Edit Scan Schedule"
|
||||
onSelect={() => onOpenScheduleEditor()}
|
||||
disabled={scheduleProviderCount === 0}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : `Test Connections (${testCount})`}
|
||||
@@ -226,6 +266,8 @@ export function DataTableRowActions({
|
||||
hasSelection,
|
||||
isRowSelected,
|
||||
testableProviderIds,
|
||||
selectedScheduleProviderIds = [],
|
||||
selectedScheduleProviders = [],
|
||||
onClearSelection,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
@@ -266,6 +308,10 @@ export function DataTableRowActions({
|
||||
const childTestableIds = isOrganizationRow
|
||||
? collectTestableChildProviderIds(rowData.subRows)
|
||||
: [];
|
||||
const childScheduleProviders = isOrganizationRow
|
||||
? collectChildScheduleProviders(rowData.subRows)
|
||||
: [];
|
||||
const childScheduleProviderIds = isOrganizationRow ? rowData.providerIds : [];
|
||||
|
||||
const handleBulkTest = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
@@ -329,8 +375,17 @@ export function DataTableRowActions({
|
||||
await handleBulkTest(childTestableIds);
|
||||
};
|
||||
|
||||
const openScheduleEditor = async () => {
|
||||
if (!providerId) {
|
||||
const openScheduleEditor = async (
|
||||
targetProviders: ScanScheduleProvider[] = scheduleProvider
|
||||
? [scheduleProvider]
|
||||
: [],
|
||||
targetProviderIds: string[] = targetProviders.map(
|
||||
(target) => target.providerId,
|
||||
),
|
||||
) => {
|
||||
const targetProviderId = targetProviderIds[0];
|
||||
|
||||
if (!targetProviderId) {
|
||||
setScheduleState({
|
||||
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
|
||||
message: "Provider ID is not available.",
|
||||
@@ -342,7 +397,7 @@ export function DataTableRowActions({
|
||||
setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
|
||||
setIsScheduleOpen(true);
|
||||
|
||||
const response = (await getSchedule(providerId)) as
|
||||
const response = (await getSchedule(targetProviderId)) as
|
||||
| ScheduleApiResponse
|
||||
| { error?: string };
|
||||
|
||||
@@ -367,38 +422,81 @@ export function DataTableRowActions({
|
||||
if (hasSelection && isRowSelected) {
|
||||
const bulkCount =
|
||||
testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : "";
|
||||
const selectedScheduleProviderCount = selectedScheduleProviderIds.length;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={testableProviderIds.length === 0 || loading}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
<>
|
||||
<EditScanScheduleModal
|
||||
open={isScheduleOpen}
|
||||
onOpenChange={setIsScheduleOpen}
|
||||
providers={selectedScheduleProviders}
|
||||
providerIds={selectedScheduleProviderIds}
|
||||
targetName="Selected providers"
|
||||
state={scheduleState}
|
||||
onSaved={onClearSelection}
|
||||
/>
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
{canEditSchedule && selectedScheduleProviderCount > 0 && (
|
||||
<ActionDropdownItem
|
||||
icon={<CalendarClock />}
|
||||
label={`Edit Scan Schedule (${selectedScheduleProviderCount})`}
|
||||
onSelect={() =>
|
||||
void openScheduleEditor(
|
||||
selectedScheduleProviders,
|
||||
selectedScheduleProviderIds,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={testableProviderIds.length === 0 || loading}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Organization / Organization Unit row actions
|
||||
if (isProvidersOrganizationRow(rowData) && orgGroupKind) {
|
||||
return (
|
||||
<OrgGroupDropdownActions
|
||||
rowData={rowData}
|
||||
loading={loading}
|
||||
hasSelection={hasSelection}
|
||||
testableProviderIds={testableProviderIds}
|
||||
childTestableIds={childTestableIds}
|
||||
onClearSelection={onClearSelection}
|
||||
onBulkTest={handleBulkTest}
|
||||
onTestChildConnections={handleTestChildConnections}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
/>
|
||||
<>
|
||||
<EditScanScheduleModal
|
||||
open={isScheduleOpen}
|
||||
onOpenChange={setIsScheduleOpen}
|
||||
providers={childScheduleProviders}
|
||||
providerIds={childScheduleProviderIds}
|
||||
targetName={rowData.name}
|
||||
targetId={rowData.externalId ?? undefined}
|
||||
state={scheduleState}
|
||||
/>
|
||||
<OrgGroupDropdownActions
|
||||
rowData={rowData}
|
||||
loading={loading}
|
||||
canEditSchedule={canEditSchedule}
|
||||
hasSelection={hasSelection}
|
||||
testableProviderIds={testableProviderIds}
|
||||
childTestableIds={childTestableIds}
|
||||
scheduleProviderCount={childScheduleProviderIds.length}
|
||||
onClearSelection={onClearSelection}
|
||||
onBulkTest={handleBulkTest}
|
||||
onTestChildConnections={handleTestChildConnections}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
onOpenScheduleEditor={() =>
|
||||
void openScheduleEditor(
|
||||
childScheduleProviders,
|
||||
childScheduleProviderIds,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
@@ -89,6 +90,10 @@ export function ProviderWizardModal({
|
||||
enabled: open,
|
||||
refreshToken: scrollHintRefreshToken,
|
||||
});
|
||||
const {
|
||||
capability: resolvedScanScheduleCapability,
|
||||
isScheduleCapabilityLoading,
|
||||
} = useScanScheduleCapability(scanScheduleCapability);
|
||||
const docsDestination = getProviderWizardDocsDestination(docsLink);
|
||||
|
||||
return (
|
||||
@@ -200,8 +205,9 @@ export function ProviderWizardModal({
|
||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
||||
onClose={handleClose}
|
||||
onFooterChange={setFooterConfig}
|
||||
capability={scanScheduleCapability}
|
||||
capability={resolvedScanScheduleCapability}
|
||||
isScanLimitReached={isScanLimitReached}
|
||||
isScheduleCapabilityLoading={isScheduleCapabilityLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -255,8 +261,9 @@ export function ProviderWizardModal({
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
capability={scanScheduleCapability}
|
||||
capability={resolvedScanScheduleCapability}
|
||||
isScanLimitReached={isScanLimitReached}
|
||||
isScheduleCapabilityLoading={isScheduleCapabilityLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -295,6 +295,36 @@ describe("LaunchStep", () => {
|
||||
expect.objectContaining({ title: "Unable to save scan schedule" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables launch actions while schedule capability is loading", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
seedConnectedProvider();
|
||||
|
||||
render(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
isScheduleCapabilityLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.findByText("Loading scan options...");
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true);
|
||||
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
|
||||
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
expect(scheduleDailyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prowler Cloud trial/onboarding (manual scan only)", () => {
|
||||
|
||||
@@ -67,6 +67,11 @@ interface LaunchStepProps {
|
||||
* Cloud-only signal; never set in OSS.
|
||||
*/
|
||||
isScanLimitReached?: boolean;
|
||||
/**
|
||||
* Cloud-only loading state while billing is resolved into a schedule
|
||||
* capability. OSS leaves it false.
|
||||
*/
|
||||
isScheduleCapabilityLoading?: boolean;
|
||||
}
|
||||
|
||||
export function LaunchStep({
|
||||
@@ -75,6 +80,7 @@ export function LaunchStep({
|
||||
onFooterChange,
|
||||
capability: capabilityProp,
|
||||
isScanLimitReached = false,
|
||||
isScheduleCapabilityLoading = false,
|
||||
}: LaunchStepProps) {
|
||||
const { toast } = useToast();
|
||||
const { providerAlias, providerId, providerType, providerUid } =
|
||||
@@ -82,6 +88,7 @@ export function LaunchStep({
|
||||
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
|
||||
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
||||
const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED;
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [mode, setMode] = useState<LaunchMode>(
|
||||
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
||||
@@ -93,7 +100,12 @@ export function LaunchStep({
|
||||
|
||||
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
|
||||
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
|
||||
const isActionBlocked = isLaunching || !providerId || isLimitBlocked;
|
||||
const isActionBlocked =
|
||||
isLaunching ||
|
||||
isScheduleCapabilityLoading ||
|
||||
!providerId ||
|
||||
isBlocked ||
|
||||
isLimitBlocked;
|
||||
const launchInitialScan = useWatch({
|
||||
control: form.control,
|
||||
name: "launchInitialScan",
|
||||
@@ -111,15 +123,21 @@ export function LaunchStep({
|
||||
return launchInitialScan ? "Save and launch scan" : "Save";
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdvanced && mode !== LAUNCH_MODE.NOW) {
|
||||
setMode(LAUNCH_MODE.NOW);
|
||||
}
|
||||
}, [isAdvanced, mode]);
|
||||
|
||||
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
|
||||
if (!providerId) return null;
|
||||
if (!providerId || isBlocked) return null;
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
return scanOnDemand(formData);
|
||||
};
|
||||
|
||||
const handleManualScan = async () => {
|
||||
if (isScanLimitReached) {
|
||||
if (isActionBlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,7 +168,7 @@ export function LaunchStep({
|
||||
};
|
||||
|
||||
const handleSaveSchedule = form.handleSubmit(async (values) => {
|
||||
if (!providerId) {
|
||||
if (!providerId || isBlocked || isScheduleCapabilityLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,6 +225,10 @@ export function LaunchStep({
|
||||
// always invokes the current closure without re-running on every render.
|
||||
const actionRef = useRef<() => void>(() => {});
|
||||
actionRef.current = () => {
|
||||
if (isBlocked || isScheduleCapabilityLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isScheduleMode) {
|
||||
void handleManualScan();
|
||||
return;
|
||||
@@ -218,7 +240,7 @@ export function LaunchStep({
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
backDisabled: isLaunching,
|
||||
backDisabled: isLaunching || isScheduleCapabilityLoading,
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel,
|
||||
@@ -229,6 +251,7 @@ export function LaunchStep({
|
||||
}, [
|
||||
isActionBlocked,
|
||||
isLaunching,
|
||||
isScheduleCapabilityLoading,
|
||||
actionLabel,
|
||||
isScheduleMode,
|
||||
launchInitialScan,
|
||||
@@ -237,13 +260,17 @@ export function LaunchStep({
|
||||
onFooterChange,
|
||||
]);
|
||||
|
||||
if (isLaunching) {
|
||||
if (isLaunching || isScheduleCapabilityLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">
|
||||
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
|
||||
{isScheduleCapabilityLoading
|
||||
? "Loading scan options..."
|
||||
: !isScheduleMode
|
||||
? "Launching scan..."
|
||||
: "Saving scan schedule..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +311,11 @@ export function LaunchStep({
|
||||
aria-label="Scan mode"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
|
||||
<RadioGroupItem
|
||||
value={LAUNCH_MODE.NOW}
|
||||
aria-label="Run now"
|
||||
disabled={isBlocked}
|
||||
/>
|
||||
Run now
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -295,6 +326,7 @@ export function LaunchStep({
|
||||
/>
|
||||
On a schedule
|
||||
{!isAdvanced &&
|
||||
!isBlocked &&
|
||||
(isManualOnly ? (
|
||||
<CloudFeatureBadge label="Requires subscription" size="sm" />
|
||||
) : (
|
||||
@@ -304,14 +336,14 @@ export function LaunchStep({
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{!isAdvanced && (
|
||||
{!isAdvanced && !isBlocked && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for this account. Run now to get
|
||||
immediate findings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLimitBlocked && (
|
||||
{(isLimitBlocked || isBlocked) && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
|
||||
@@ -432,6 +432,28 @@ describe("LaunchScanModal", () => {
|
||||
expect(getScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[provider]}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
|
||||
expect(getScheduleMock).not.toHaveBeenCalled();
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the mode selector and blocks over-limit accounts in MANUAL_ONLY", () => {
|
||||
render(
|
||||
<LaunchScanModal
|
||||
@@ -449,5 +471,31 @@ describe("LaunchScanModal", () => {
|
||||
screen.getByRole("button", { name: /launch scan/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("blocks scans and schedules in BLOCKED", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[provider]}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /launch scan/i }),
|
||||
).toBeDisabled();
|
||||
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
||||
expect(getScheduleMock).not.toHaveBeenCalled();
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,10 @@ function LaunchScanForm({
|
||||
|
||||
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
||||
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
const isScheduleMode = mode === LAUNCH_MODE.SCHEDULE;
|
||||
const isBlocked =
|
||||
capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED ||
|
||||
(isManualOnly && isScanLimitReached);
|
||||
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
|
||||
|
||||
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
|
||||
const providerId = useWatch({ control: form.control, name: "providerId" });
|
||||
@@ -152,11 +155,14 @@ function LaunchScanForm({
|
||||
};
|
||||
|
||||
const handleModeChange = (nextMode: string) => {
|
||||
if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return;
|
||||
setMode(nextMode as LaunchMode);
|
||||
if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId);
|
||||
};
|
||||
|
||||
const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => {
|
||||
if (isBlocked) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
const trimmedAlias = scanAlias?.trim();
|
||||
@@ -192,6 +198,8 @@ function LaunchScanForm({
|
||||
});
|
||||
|
||||
const saveSchedule = async () => {
|
||||
if (isBlocked || !isAdvanced) return;
|
||||
|
||||
const providerValid = await form.trigger("providerId");
|
||||
if (!providerValid) return;
|
||||
|
||||
@@ -233,6 +241,8 @@ function LaunchScanForm({
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isBlocked) return;
|
||||
|
||||
if (isScheduleMode) {
|
||||
void saveSchedule();
|
||||
return;
|
||||
@@ -246,7 +256,6 @@ function LaunchScanForm({
|
||||
const isSubmitting =
|
||||
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
|
||||
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
|
||||
const isLimitBlocked = isManualOnly && isScanLimitReached;
|
||||
|
||||
return (
|
||||
// min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
|
||||
@@ -273,7 +282,7 @@ function LaunchScanForm({
|
||||
{providerError && <FieldError>{providerError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
{!isManualOnly && (
|
||||
{!isManualOnly && !isBlocked && (
|
||||
<Field>
|
||||
<FieldLabel>Mode</FieldLabel>
|
||||
<RadioGroup
|
||||
@@ -299,7 +308,7 @@ function LaunchScanForm({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isLimitBlocked && (
|
||||
{isBlocked && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
@@ -355,10 +364,7 @@ function LaunchScanForm({
|
||||
}
|
||||
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
|
||||
isDisabled={
|
||||
isSubmitting ||
|
||||
!providers.length ||
|
||||
isScheduleLoading ||
|
||||
isLimitBlocked
|
||||
isSubmitting || !providers.length || isScheduleLoading || isBlocked
|
||||
}
|
||||
rightIcon={<Rocket className="size-4" />}
|
||||
/>
|
||||
|
||||
@@ -2,13 +2,19 @@ import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { refreshMock, removeScheduleMock, toastMock, updateScheduleMock } =
|
||||
vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
removeScheduleMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
updateScheduleMock: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
refreshMock,
|
||||
removeScheduleMock,
|
||||
toastMock,
|
||||
updateScheduleMock,
|
||||
updateSchedulesBulkMock,
|
||||
} = vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
removeScheduleMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
updateScheduleMock: vi.fn(),
|
||||
updateSchedulesBulkMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
@@ -17,14 +23,44 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/actions/schedules", () => ({
|
||||
removeSchedule: removeScheduleMock,
|
||||
updateSchedule: updateScheduleMock,
|
||||
updateSchedulesBulk: updateSchedulesBulkMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/toast", () => ({
|
||||
toast: toastMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIconStack: ({ items }: { items: Array<{ type: string }> }) => (
|
||||
<div data-testid="provider-type-icon-stack">
|
||||
{items.map((item) => (
|
||||
<span key={item.type}>{item.type}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
EntityInfo: () => null,
|
||||
EntityInfo: ({
|
||||
badge,
|
||||
cloudProvider,
|
||||
entityAlias,
|
||||
icon,
|
||||
}: {
|
||||
badge?: string;
|
||||
cloudProvider?: string;
|
||||
entityAlias?: string;
|
||||
icon?: React.ReactNode;
|
||||
}) => (
|
||||
<div data-testid="entity-info">
|
||||
{cloudProvider && (
|
||||
<span data-testid="single-cloud-provider">{cloudProvider}</span>
|
||||
)}
|
||||
{icon}
|
||||
{entityAlias && <span>{entityAlias}</span>}
|
||||
{badge && <span>{badge}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
@@ -58,6 +94,32 @@ const provider = {
|
||||
providerAlias: "Production",
|
||||
};
|
||||
|
||||
const organizationProviders = [
|
||||
provider,
|
||||
{
|
||||
providerId: "p2",
|
||||
providerType: "aws" as const,
|
||||
providerUid: "210987654321",
|
||||
providerAlias: "Staging",
|
||||
},
|
||||
];
|
||||
|
||||
const multiCloudProviders = [
|
||||
provider,
|
||||
{
|
||||
providerId: "p2",
|
||||
providerType: "azure" as const,
|
||||
providerUid: "azure-subscription",
|
||||
providerAlias: "Azure Prod",
|
||||
},
|
||||
{
|
||||
providerId: "p3",
|
||||
providerType: "aws" as const,
|
||||
providerUid: "210987654321",
|
||||
providerAlias: "AWS Staging",
|
||||
},
|
||||
];
|
||||
|
||||
const schedule: ScheduleProps = {
|
||||
type: "schedules",
|
||||
id: "p1",
|
||||
@@ -89,6 +151,7 @@ describe("EditScanScheduleModal remove flow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
removeScheduleMock.mockResolvedValue({ success: true });
|
||||
updateSchedulesBulkMock.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it("asks for confirmation before removing the schedule", async () => {
|
||||
@@ -162,4 +225,89 @@ describe("EditScanScheduleModal remove flow", () => {
|
||||
|
||||
expect(removeScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves organization schedules through the bulk endpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EditScanScheduleModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={organizationProviders}
|
||||
targetName="My AWS Organization"
|
||||
targetId="o-abc123def4"
|
||||
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
|
||||
["p1", "p2"],
|
||||
expect.objectContaining({
|
||||
scan_enabled: true,
|
||||
scan_frequency: "DAILY",
|
||||
scan_hour: 4,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "The scan schedule was updated for 2 providers.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit provider ids for organization bulk schedules", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EditScanScheduleModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[organizationProviders[0]]}
|
||||
providerIds={["p1", "p2", "p3"]}
|
||||
targetName="My AWS Organization"
|
||||
targetId="o-abc123def4"
|
||||
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
|
||||
["p1", "p2", "p3"],
|
||||
expect.objectContaining({
|
||||
scan_enabled: true,
|
||||
scan_frequency: "DAILY",
|
||||
scan_hour: 4,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "The scan schedule was updated for 3 providers.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one logo per selected provider type in bulk mode", () => {
|
||||
render(
|
||||
<EditScanScheduleModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={multiCloudProviders}
|
||||
targetName="Selected providers"
|
||||
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stack = screen.getByTestId("provider-type-icon-stack");
|
||||
expect(stack).toHaveTextContent("aws");
|
||||
expect(stack).toHaveTextContent("azure");
|
||||
expect(
|
||||
screen.queryByTestId("single-cloud-provider"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,21 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { removeSchedule, updateSchedule } from "@/actions/schedules";
|
||||
import {
|
||||
removeSchedule,
|
||||
updateSchedule,
|
||||
updateSchedulesBulk,
|
||||
} from "@/actions/schedules";
|
||||
import {
|
||||
ProviderTypeIconStack,
|
||||
type ProviderTypeIconStackItem,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Button, FieldError } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import {
|
||||
buildScheduleUpdatePayload,
|
||||
getScheduleFormValues,
|
||||
@@ -19,17 +28,13 @@ import {
|
||||
scheduleFormSchema,
|
||||
} from "@/lib/schedules";
|
||||
import type { ProviderType, ScheduleProps } from "@/types";
|
||||
import type { ScheduleFormValues } from "@/types/schedules";
|
||||
import type {
|
||||
ScanScheduleProvider,
|
||||
ScheduleFormValues,
|
||||
} from "@/types/schedules";
|
||||
|
||||
import { ScanScheduleFields } from "./scan-schedule-fields";
|
||||
|
||||
export interface ScanScheduleProvider {
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerUid: string;
|
||||
providerAlias: string | null;
|
||||
}
|
||||
|
||||
export const EDIT_SCAN_SCHEDULE_STATE = {
|
||||
LOADING: "loading",
|
||||
LOADED: "loaded",
|
||||
@@ -45,22 +50,56 @@ export type EditScanScheduleState =
|
||||
| { kind: typeof EDIT_SCAN_SCHEDULE_STATE.ERROR; message: string };
|
||||
|
||||
interface EditScanScheduleFormProps {
|
||||
provider: ScanScheduleProvider;
|
||||
provider?: ScanScheduleProvider;
|
||||
providers?: ScanScheduleProvider[];
|
||||
providerIds?: string[];
|
||||
targetName?: string;
|
||||
targetId?: string;
|
||||
schedule: ScheduleProps | null;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
interface EditScanScheduleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider?: ScanScheduleProvider;
|
||||
providers?: ScanScheduleProvider[];
|
||||
providerIds?: string[];
|
||||
targetName?: string;
|
||||
targetId?: string;
|
||||
state: EditScanScheduleState;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
function getBulkProviderTypeIconItems(
|
||||
providers: ScanScheduleProvider[],
|
||||
): ProviderTypeIconStackItem[] {
|
||||
const seen = new Set<ProviderType>();
|
||||
const items: ProviderTypeIconStackItem[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
if (seen.has(provider.providerType)) continue;
|
||||
seen.add(provider.providerType);
|
||||
items.push({
|
||||
key: provider.providerType,
|
||||
type: provider.providerType,
|
||||
tooltip: provider.providerType,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function EditScanScheduleForm({
|
||||
provider,
|
||||
providers,
|
||||
providerIds,
|
||||
targetName,
|
||||
targetId,
|
||||
schedule,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditScanScheduleFormProps) {
|
||||
const router = useRouter();
|
||||
const [isConfirmRemoveOpen, setIsConfirmRemoveOpen] = useState(false);
|
||||
@@ -72,12 +111,23 @@ function EditScanScheduleForm({
|
||||
const hasSchedule = schedule
|
||||
? isScheduleConfigured(schedule.attributes)
|
||||
: false;
|
||||
const targetProviders = providers ?? (provider ? [provider] : []);
|
||||
const targetProviderIds =
|
||||
providerIds ?? targetProviders.map((target) => target.providerId);
|
||||
const referenceProvider = targetProviders[0];
|
||||
const isBulk = providers !== undefined || providerIds !== undefined;
|
||||
const bulkProviderTypeIconItems = isBulk
|
||||
? getBulkProviderTypeIconItems(targetProviders)
|
||||
: [];
|
||||
const providerCountLabel = `${targetProviderIds.length} provider${
|
||||
targetProviderIds.length === 1 ? "" : "s"
|
||||
}`;
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
const result = await updateSchedule(
|
||||
provider.providerId,
|
||||
buildScheduleUpdatePayload(values),
|
||||
);
|
||||
const payload = buildScheduleUpdatePayload(values);
|
||||
const result = isBulk
|
||||
? await updateSchedulesBulk(targetProviderIds, payload)
|
||||
: await updateSchedule(targetProviderIds[0], payload);
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", { message: String(result.error) });
|
||||
@@ -86,27 +136,38 @@ function EditScanScheduleForm({
|
||||
|
||||
toast({
|
||||
title: "Scan schedule saved",
|
||||
description: "The scan schedule was updated successfully.",
|
||||
description: isBulk
|
||||
? `The scan schedule was updated for ${providerCountLabel}.`
|
||||
: "The scan schedule was updated successfully.",
|
||||
});
|
||||
onSaved?.();
|
||||
onClose();
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsRemoving(true);
|
||||
const result = await removeSchedule(provider.providerId);
|
||||
const results = await runWithConcurrencyLimit(
|
||||
targetProviderIds,
|
||||
10,
|
||||
(providerId) => removeSchedule(providerId),
|
||||
);
|
||||
setIsRemoving(false);
|
||||
setIsConfirmRemoveOpen(false);
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", { message: String(result.error) });
|
||||
const failedResult = results.find((result) => result?.error);
|
||||
if (failedResult?.error) {
|
||||
form.setError("root", { message: String(failedResult.error) });
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Scan schedule removed",
|
||||
description: "The scan schedule was removed successfully.",
|
||||
description: isBulk
|
||||
? `The scan schedule was removed for ${providerCountLabel}.`
|
||||
: "The scan schedule was removed successfully.",
|
||||
});
|
||||
onSaved?.();
|
||||
onClose();
|
||||
router.refresh();
|
||||
};
|
||||
@@ -116,11 +177,30 @@ function EditScanScheduleForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<EntityInfo
|
||||
cloudProvider={provider.providerType}
|
||||
entityAlias={provider.providerAlias ?? provider.providerUid}
|
||||
entityId={provider.providerUid}
|
||||
/>
|
||||
{referenceProvider && (
|
||||
<EntityInfo
|
||||
cloudProvider={isBulk ? undefined : referenceProvider.providerType}
|
||||
icon={
|
||||
isBulk && bulkProviderTypeIconItems.length > 0 ? (
|
||||
<ProviderTypeIconStack
|
||||
items={bulkProviderTypeIconItems}
|
||||
max={bulkProviderTypeIconItems.length}
|
||||
size={35}
|
||||
className="flex-wrap"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
entityAlias={
|
||||
isBulk
|
||||
? (targetName ?? providerCountLabel)
|
||||
: (referenceProvider.providerAlias ??
|
||||
referenceProvider.providerUid)
|
||||
}
|
||||
entityId={isBulk ? targetId : referenceProvider.providerUid}
|
||||
idLabel={isBulk ? "ID" : "UID"}
|
||||
badge={isBulk ? providerCountLabel : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
@@ -154,7 +234,11 @@ function EditScanScheduleForm({
|
||||
open={isConfirmRemoveOpen}
|
||||
onOpenChange={setIsConfirmRemoveOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
|
||||
description={
|
||||
isBulk
|
||||
? `This action cannot be undone. The scan schedule for these ${providerCountLabel} will be removed and scans will no longer run automatically.`
|
||||
: "This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
|
||||
}
|
||||
>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
@@ -186,9 +270,21 @@ export function EditScanScheduleModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
providers,
|
||||
providerIds,
|
||||
targetName,
|
||||
targetId,
|
||||
state,
|
||||
onSaved,
|
||||
}: EditScanScheduleModalProps) {
|
||||
const close = () => onOpenChange(false);
|
||||
const hasTarget = Boolean(
|
||||
provider || providers?.length || providerIds?.length,
|
||||
);
|
||||
const keyPrefix =
|
||||
provider?.providerId ??
|
||||
providerIds?.join(":") ??
|
||||
providers?.map((item) => item.providerId).join(":");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -214,12 +310,17 @@ export function EditScanScheduleModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && provider && (
|
||||
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && hasTarget && (
|
||||
<EditScanScheduleForm
|
||||
key={`${provider.providerId}-${state.schedule?.attributes.scan_hour ?? "none"}`}
|
||||
key={`${keyPrefix}-${state.schedule?.attributes.scan_hour ?? "none"}`}
|
||||
provider={provider}
|
||||
providers={providers}
|
||||
providerIds={providerIds}
|
||||
targetName={targetName}
|
||||
targetId={targetId}
|
||||
schedule={state.schedule}
|
||||
onClose={close}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -59,10 +59,16 @@ vi.mock("@/components/ui/table", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./scan-jobs-row-actions", () => ({
|
||||
ScanJobsRowActions: () => <button type="button" />,
|
||||
ScanJobsRowActions: ({ capability }: { capability?: string }) => (
|
||||
<button type="button">{capability ?? "no-capability"}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
} from "@/types/schedules";
|
||||
|
||||
import { getScanJobsColumns } from "./scan-jobs-columns";
|
||||
|
||||
@@ -107,9 +113,11 @@ const renderCell = (
|
||||
columnId: string,
|
||||
scan: ScanProps,
|
||||
tab: ScanJobsTab = SCAN_JOBS_TAB.COMPLETED,
|
||||
capability?: ScanScheduleCapability,
|
||||
) => {
|
||||
const column = getScanJobsColumns({
|
||||
tab,
|
||||
capability,
|
||||
}).find((item) => item.id === columnId);
|
||||
const cell = column?.cell as
|
||||
| ((context: CellContext<ScanProps, unknown>) => React.ReactNode)
|
||||
@@ -201,4 +209,19 @@ describe("getScanJobsColumns", () => {
|
||||
expect(screen.getByText("Schedule")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes scan schedule capability to row actions", () => {
|
||||
renderCell(
|
||||
"actions",
|
||||
makeCompletedScan(),
|
||||
SCAN_JOBS_TAB.COMPLETED,
|
||||
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { StatusBadge } from "@/components/ui/table/status-badge";
|
||||
import { formatLocalDate, formatLocalTimeWithZone } from "@/lib/date-utils";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
import { formatScanDuration } from "../scans.utils";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import { ScanJobsRowActions } from "./scan-jobs-row-actions";
|
||||
|
||||
interface GetScanJobsColumnsOptions {
|
||||
tab: ScanJobsTab;
|
||||
capability?: ScanScheduleCapability;
|
||||
}
|
||||
|
||||
const accountColumn: ColumnDef<ScanProps> = {
|
||||
@@ -116,12 +118,16 @@ const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<ScanProps> = {
|
||||
const actionsColumn = (
|
||||
capability?: ScanScheduleCapability,
|
||||
): ColumnDef<ScanProps> => ({
|
||||
id: "actions",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
|
||||
cell: ({ row }) => <ScanJobsRowActions scan={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<ScanJobsRowActions scan={row.original} capability={capability} />
|
||||
),
|
||||
enableSorting: false,
|
||||
};
|
||||
});
|
||||
|
||||
const durationColumn: ColumnDef<ScanProps> = {
|
||||
id: "duration",
|
||||
@@ -132,7 +138,9 @@ const durationColumn: ColumnDef<ScanProps> = {
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const activeColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const activeColumns = (
|
||||
capability?: ScanScheduleCapability,
|
||||
): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
{
|
||||
@@ -156,10 +164,12 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
actionsColumn,
|
||||
actionsColumn(capability),
|
||||
];
|
||||
|
||||
const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const completedColumns = (
|
||||
capability?: ScanScheduleCapability,
|
||||
): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
resourcesColumn,
|
||||
@@ -185,22 +195,28 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
),
|
||||
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
|
||||
},
|
||||
actionsColumn,
|
||||
actionsColumn(capability),
|
||||
];
|
||||
|
||||
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const scheduledColumns = (
|
||||
capability?: ScanScheduleCapability,
|
||||
): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scheduledScanScheduleColumn,
|
||||
nextScanColumn,
|
||||
lastScanColumn,
|
||||
actionsColumn,
|
||||
actionsColumn(capability),
|
||||
];
|
||||
|
||||
export function getScanJobsColumns(
|
||||
options: GetScanJobsColumnsOptions,
|
||||
): ColumnDef<ScanProps>[] {
|
||||
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) return scheduledColumns();
|
||||
if (options.tab === SCAN_JOBS_TAB.ACTIVE) return activeColumns();
|
||||
return completedColumns();
|
||||
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) {
|
||||
return scheduledColumns(options.capability);
|
||||
}
|
||||
if (options.tab === SCAN_JOBS_TAB.ACTIVE) {
|
||||
return activeColumns(options.capability);
|
||||
}
|
||||
return completedColumns(options.capability);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanProps } from "@/types";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
|
||||
|
||||
@@ -187,6 +188,52 @@ describe("ScanJobsRowActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Edit Scan Schedule for manual-only Cloud scan rows", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Edit Scan Schedule for blocked Cloud scan rows", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links completed scans to compliance from the actions menu", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
EDIT_SCAN_SCHEDULE_STATE,
|
||||
EditScanScheduleModal,
|
||||
type EditScanScheduleState,
|
||||
type ScanScheduleProvider,
|
||||
} from "@/components/scans/schedule/edit-scan-schedule-modal";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -38,6 +37,7 @@ import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
type ScanScheduleProvider,
|
||||
} from "@/types/schedules";
|
||||
|
||||
interface ScanJobsRowActionsProps {
|
||||
|
||||
@@ -2,9 +2,14 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SCAN_JOBS_TAB, type ScanProps } from "@/types";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
import { ScanJobsTable } from "./scan-jobs-table";
|
||||
|
||||
const { getScanJobsColumnsMock } = vi.hoisted(() => ({
|
||||
getScanJobsColumnsMock: vi.fn((_options: unknown) => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: ({ data }: { data: ScanProps[] }) => (
|
||||
<div data-testid="scan-jobs-data-table">{data.length}</div>
|
||||
@@ -12,7 +17,7 @@ vi.mock("@/components/ui/table", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./scan-jobs-columns", () => ({
|
||||
getScanJobsColumns: () => [],
|
||||
getScanJobsColumns: (options: unknown) => getScanJobsColumnsMock(options),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-refresh", () => ({
|
||||
@@ -96,4 +101,20 @@ describe("ScanJobsTable", () => {
|
||||
screen.queryByTestId("no-scans-empty-state"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes scan schedule capability to scan job columns", () => {
|
||||
render(
|
||||
<ScanJobsTable
|
||||
data={[]}
|
||||
tab={SCAN_JOBS_TAB.ACTIVE}
|
||||
hasFilters
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getScanJobsColumnsMock).toHaveBeenCalledWith({
|
||||
tab: SCAN_JOBS_TAB.ACTIVE,
|
||||
capability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
import { AutoRefresh } from "../auto-refresh";
|
||||
import { NoScansEmptyState } from "../no-scans-empty-state";
|
||||
@@ -13,6 +14,7 @@ interface ScanJobsTableProps {
|
||||
meta?: MetaDataProps;
|
||||
tab: ScanJobsTab;
|
||||
hasFilters?: boolean;
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
}
|
||||
|
||||
const REFRESHING_STATES = ["available", "executing"] as const;
|
||||
@@ -22,13 +24,17 @@ export function ScanJobsTable({
|
||||
meta,
|
||||
tab,
|
||||
hasFilters = false,
|
||||
scanScheduleCapability,
|
||||
}: ScanJobsTableProps) {
|
||||
const hasRefreshingScan = data.some((scan) =>
|
||||
REFRESHING_STATES.includes(
|
||||
scan.attributes.state as (typeof REFRESHING_STATES)[number],
|
||||
),
|
||||
);
|
||||
const columns = getScanJobsColumns({ tab });
|
||||
const columns = getScanJobsColumns({
|
||||
tab,
|
||||
capability: scanScheduleCapability,
|
||||
});
|
||||
const showEmptyState = data.length === 0 && !hasFilters;
|
||||
|
||||
return (
|
||||
|
||||
@@ -162,6 +162,7 @@ describe("DataTable", () => {
|
||||
expect(actionsCell).toHaveClass("right-0");
|
||||
expect(actionsCell).toHaveClass("z-20");
|
||||
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
|
||||
expect(actionsCell).toHaveClass("last:rounded-r-none!");
|
||||
expect(actionsCell).not.toHaveClass("border-l");
|
||||
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
|
||||
expect(actionsCell).toHaveClass("before:from-transparent");
|
||||
|
||||
@@ -46,7 +46,7 @@ type DataTableRowAttributes = {
|
||||
const DEFAULT_COLUMN_SIZE = 150;
|
||||
const ACTIONS_COLUMN_ID = "actions";
|
||||
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
|
||||
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
|
||||
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
|
||||
|
||||
const getStickyActionColumnClassName = (
|
||||
columnId: string,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
import { useScanScheduleCapability } from "./use-scan-schedule-capability";
|
||||
|
||||
describe("useScanScheduleCapability", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns DAILY_LEGACY for OSS without loading", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useScanScheduleCapability());
|
||||
|
||||
// Then
|
||||
expect(result.current).toEqual({
|
||||
capability: SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY,
|
||||
isScheduleCapabilityLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ADVANCED for Cloud env without loading", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useScanScheduleCapability());
|
||||
|
||||
// Then
|
||||
expect(result.current).toEqual({
|
||||
capability: SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
isScheduleCapabilityLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit capability overrides", () => {
|
||||
// Given / When
|
||||
const { result } = renderHook(() =>
|
||||
useScanScheduleCapability(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result.current).toEqual({
|
||||
capability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
isScheduleCapabilityLoading: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
interface UseScanScheduleCapabilityResult {
|
||||
capability: ScanScheduleCapability;
|
||||
isScheduleCapabilityLoading: boolean;
|
||||
}
|
||||
|
||||
export function useScanScheduleCapability(
|
||||
capabilityOverride?: ScanScheduleCapability,
|
||||
): UseScanScheduleCapabilityResult {
|
||||
return {
|
||||
capability: capabilityOverride ?? getScanScheduleCapability(isCloud()),
|
||||
isScheduleCapabilityLoading: false,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,16 @@ export const scheduleFormSchema = z.object({
|
||||
launchInitialScan: z.boolean(),
|
||||
});
|
||||
|
||||
export const scheduleUpdatePayloadSchema = z.object({
|
||||
scan_enabled: z.boolean(),
|
||||
scan_frequency: z.enum(SCHEDULE_FREQUENCY),
|
||||
scan_hour: z.number().int().min(0).max(23),
|
||||
scan_timezone: z.string().min(1),
|
||||
scan_interval_hours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN).nullable(),
|
||||
scan_day_of_week: z.number().int().min(0).max(6).nullable(),
|
||||
scan_day_of_month: z.number().int().min(1).max(28).nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Default scan-schedule capability for the current environment.
|
||||
*
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface ProvidersOrganizationRow {
|
||||
parentExternalId: string | null;
|
||||
organizationId: string | null;
|
||||
providerCount: number;
|
||||
providerIds: string[];
|
||||
subRows: ProvidersTableRow[];
|
||||
}
|
||||
|
||||
|
||||
+40
-3
@@ -1,4 +1,4 @@
|
||||
import type { ProviderProps } from "./providers";
|
||||
import type { ProviderProps, ProviderType } from "./providers";
|
||||
|
||||
export const SCHEDULE_FREQUENCY = {
|
||||
DAILY: "DAILY",
|
||||
@@ -26,17 +26,21 @@ export const SCHEDULE_WEEKDAY_LABELS = [
|
||||
* the runtime environment (Cloud vs non-Cloud); the prowler-cloud overlay
|
||||
* computes a billing-aware capability and injects it via the `capability` prop.
|
||||
*
|
||||
* - `ADVANCED`: full scheduling through the new `/schedules/{providerId}` API
|
||||
* (Prowler Cloud, subscribed/paid).
|
||||
* - `ADVANCED`: full scheduling through the new schedules API —
|
||||
* `/schedules/{providerId}` for a single provider, `/schedules/bulk` for the
|
||||
* organization flow (Prowler Cloud, subscribed/paid).
|
||||
* - `DAILY_LEGACY`: Prowler OSS / non-Cloud. Only the legacy `Daily` schedule
|
||||
* (`/schedules/daily`) plus optional on-demand scans are allowed.
|
||||
* - `MANUAL_ONLY`: Prowler Cloud trial/onboarding. No schedules at all, only a
|
||||
* manual on-demand scan subject to the account quota.
|
||||
* - `BLOCKED`: Prowler Cloud account over the scan limit. No scan or schedule
|
||||
* action is available.
|
||||
*/
|
||||
export const SCAN_SCHEDULE_CAPABILITY = {
|
||||
ADVANCED: "ADVANCED",
|
||||
DAILY_LEGACY: "DAILY_LEGACY",
|
||||
MANUAL_ONLY: "MANUAL_ONLY",
|
||||
BLOCKED: "BLOCKED",
|
||||
} as const;
|
||||
|
||||
export type ScanScheduleCapability =
|
||||
@@ -87,6 +91,39 @@ export interface ScheduleUpdatePayload {
|
||||
scan_day_of_month: number | null;
|
||||
}
|
||||
|
||||
/** Per-provider failure, as returned by `/schedules/bulk`: `{ id, error }`. */
|
||||
export interface SchedulesBulkFailure {
|
||||
id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SchedulesBulkAttributes {
|
||||
/** Provider ids whose schedule was committed (already excludes failures). */
|
||||
updated?: string[];
|
||||
failed?: SchedulesBulkFailure[];
|
||||
}
|
||||
|
||||
export interface SchedulesBulkData {
|
||||
type: "schedules-bulk";
|
||||
id?: string;
|
||||
attributes?: SchedulesBulkAttributes;
|
||||
}
|
||||
|
||||
export interface SchedulesBulkResponse {
|
||||
data?: SchedulesBulkData;
|
||||
error?: unknown;
|
||||
errors?: unknown;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/** Minimal provider identity needed to render and target schedule actions. */
|
||||
export interface ScanScheduleProvider {
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerUid: string;
|
||||
providerAlias: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleFormValues {
|
||||
frequency: ScheduleFrequency;
|
||||
hour: number;
|
||||
|
||||
Reference in New Issue
Block a user