diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index cbdb0b1296..d8c5d75456 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -28,6 +28,10 @@ import { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + sanitizeErrorMessage: (message: string, fallback: string) => + / { }); }); + it("returns a generic error when a gateway returns HTML", async () => { + // Given + const mockResponse = new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 502, + }); + }); + it("returns generic error when fetch throws", async () => { // Given fetchMock.mockRejectedValue(new Error("Network failure")); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 8af2576852..3ab84efa4f 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -4,7 +4,13 @@ import { redirect } from "next/navigation"; import { getLatestFindings } from "@/actions/findings"; import { listOrganizationsSafe } from "@/actions/organizations/organizations"; -import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + FINDINGS_FILTERED_SORT, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + sanitizeErrorMessage, +} from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { OrganizationResource } from "@/types/organizations"; @@ -193,22 +199,27 @@ export const getResourceEvents = async ( if (!response.ok) { const rawText = await response.text().catch(() => ""); + const contentType = + response.headers.get("content-type")?.toLowerCase() || ""; const defaultError = "An error occurred while fetching events."; + const fallbackError = contentType.includes("text/html") + ? GENERIC_SERVER_ERROR_MESSAGE + : response.statusText || defaultError; try { const errorData = rawText ? JSON.parse(rawText) : null; + const errorMessage = + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + fallbackError; return { - error: - errorData?.errors?.[0]?.detail || - errorData?.error || - errorData?.message || - rawText || - response.statusText || - defaultError, + error: sanitizeErrorMessage(String(errorMessage), fallbackError), status: response.status, }; } catch { return { - error: rawText || response.statusText || defaultError, + error: sanitizeErrorMessage(rawText || fallbackError, fallbackError), status: response.status, }; } diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index ae82ba860d..a7f7fd0c7e 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -14,6 +14,8 @@ const { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", getAuthHeaders: getAuthHeadersMock, getErrorMessage: (error: unknown) => error instanceof Error ? error.message : String(error), @@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addScanOperation: vi.fn(), })); -import { launchOrganizationScans } from "./scans"; +import { getExportsZip, launchOrganizationScans } from "./scans"; describe("launchOrganizationScans", () => { beforeEach(() => { @@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => { expect(result.failureCount).toBe(0); }); }); + +describe("getExportsZip", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("returns a generic server error when the report endpoint returns HTML", async () => { + // Given + fetchMock.mockResolvedValue( + new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ), + ); + + // When + const result = await getExportsZip("scan-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index fb32a6286e..5a6ddb7a29 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, @@ -162,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => { const url = new URL(`${apiBaseUrl}/schedules/daily`); + const body = { + data: { + type: "daily-schedules", + attributes: { + provider_id: providerId, + }, + }, + }; + try { const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify({ - data: { - type: "daily-schedules", - attributes: { - provider_id: providerId, - }, - }, - }), + body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); @@ -249,6 +256,27 @@ export const launchOrganizationScans = async ( return summary; }; +async function getScanReportErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -300,11 +328,11 @@ export const getExportsZip = async (scanId: string) => { } if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, "Unable to fetch scan report. Contact support if the issue continues.", + ), ); } @@ -375,10 +403,11 @@ const _fetchScanBinary = async ( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, + ), ); } diff --git a/ui/actions/schedules/index.ts b/ui/actions/schedules/index.ts new file mode 100644 index 0000000000..7e6e7b459a --- /dev/null +++ b/ui/actions/schedules/index.ts @@ -0,0 +1 @@ +export * from "./schedules"; diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts new file mode 100644 index 0000000000..9e05799116 --- /dev/null +++ b/ui/actions/schedules/schedules.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SCHEDULE_FREQUENCY } from "@/types/schedules"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, + revalidatePathMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { getSchedule, removeSchedule, updateSchedule } from "./schedules"; + +const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; + +const payload = { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 12, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, +}; + +describe("schedule write actions revalidate only on success", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("revalidates /scans and /providers after a successful update", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a successful delete", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the delete returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Not allowed" }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects non-UUID provider ids without issuing a request", async () => { + const malicious = "../users/me"; + + expect(await getSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(await updateSchedule(malicious, payload)).toEqual({ + error: "Invalid provider id.", + }); + expect(await removeSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts new file mode 100644 index 0000000000..c8e0d9993f --- /dev/null +++ b/ui/actions/schedules/schedules.ts @@ -0,0 +1,128 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules"; + +// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. +const providerIdSchema = z.uuid(); + +function parseProviderId(providerId: string): string | null { + const parsed = providerIdSchema.safeParse(providerId); + return parsed.success ? parsed.data : null; +} + +function revalidateScheduleViews() { + revalidatePath("/scans"); + revalidatePath("/providers"); +} + +export const getSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + url.searchParams.set("include", "provider"); + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Lists every schedule (one per provider), following pagination — the backend + * has no multi-provider filter. + */ +export const getSchedules = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const schedules: ScheduleProps[] = []; + const MAX_PAGES = 20; + + try { + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL(`${apiBaseUrl}/schedules`); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", "100"); + + const response = await fetch(url.toString(), { headers }); + + const result = await handleApiResponse(response); + if (result?.error) return result; + + schedules.push(...(result?.data ?? [])); + + const totalPages = result?.meta?.pagination?.pages ?? 1; + if (page >= totalPages) break; + } + + return { data: schedules }; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedule = async ( + providerId: string, + payload: ScheduleUpdatePayload, +) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + const body = { + data: { + type: "schedules", + id, + attributes: payload, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + 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." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 335b672af2..b5dcd6d844 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -14,16 +14,26 @@ const scansActionsMock = vi.hoisted(() => ({ getScans: vi.fn(), })); +const schedulesActionsMock = vi.hoisted(() => ({ + getSchedules: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", () => organizationsActionsMock, ); vi.mock("@/actions/scans", () => scansActionsMock); +vi.mock("@/actions/schedules", () => schedulesActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; import { ProvidersProviderRow } from "@/types/providers-table"; +import { + SCHEDULE_FREQUENCY, + type ScheduleAttributes, + type ScheduleProps, +} from "@/types/schedules"; import { buildProvidersTableRows, @@ -156,6 +166,33 @@ const toProviderRow = ( }, }); +const buildSchedule = ( + providerId: string, + overrides: Partial = {}, +): ScheduleProps => ({ + type: "schedules", + id: providerId, + attributes: { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + ...overrides, + }, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + }, +}); + +const findProviderRow = ( + rows: { id: string }[], + providerId: string, +): ProvidersProviderRow | undefined => + rows.find((row) => row.id === providerId) as ProvidersProviderRow | undefined; + describe("buildProvidersTableRows", () => { it("returns a flat providers table for OSS", () => { // Given @@ -751,4 +788,98 @@ describe("loadProvidersAccountsViewData", () => { viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER), ).toBe(true); }); + + it("surfaces the real cadence (not a hardcoded label) from a configured schedule with no materialized scan yet", async () => { + // Given — provider-1 has a WEEKLY schedule but the backend has not yet + // created a Scan row (the gap between configuring and the first fire). + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 9, + scan_day_of_week: 1, + }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — the row carries the Weekly cadence, not "Daily". + const providerRow = findProviderRow(viewData.rows, "provider-1"); + expect(providerRow?.hasSchedule).toBe(true); + expect(providerRow?.scheduleSummary?.cadence).toBe("Weekly on Monday"); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + expect( + findProviderRow(viewData.rows, "provider-2")?.scheduleSummary, + ).toBeUndefined(); + }); + + it("ignores paused or unconfigured schedules", async () => { + // Given — provider-1 paused (disabled), provider-2 never configured. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { scan_enabled: false, scan_hour: 9 }), + buildSchedule("provider-2", { scan_enabled: true, scan_hour: null }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + false, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); + + it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => { + // Given — /schedules errors, but provider-1 has a materialized scheduled scan. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ + data: [ + { + type: "scans", + id: "scan-1", + attributes: { trigger: "scheduled", state: "scheduled" }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + ], + }); + schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — scan-based path still flags the provider; no throw from the error. + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + true, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index db79452709..a37d6eb510 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -4,10 +4,16 @@ import { } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, extractSortAndKey, } from "@/lib/helper-filters"; +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + isScheduleConfigured, +} from "@/lib/schedules"; import { FilterEntity, FilterOption, @@ -27,7 +33,8 @@ import { ProvidersTableRow, ProvidersTableRowsInput, } from "@/types/providers-table"; -import { SCAN_TRIGGER, ScanProps } from "@/types/scans"; +import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans"; +import { ScheduleAttributes } from "@/types/schedules"; const PROVIDERS_STATUS_MAPPING = [ { @@ -127,22 +134,46 @@ const buildScheduledProviderIds = (scans: ScanProps[]): Set => { return scheduled; }; +// A schedule is backed by the Provider row itself, so its `/schedules` entry +// exists before the first scheduled Scan is materialized — only enabled, +// configured ones carry a displayable cadence summary. +const buildProviderScheduleSummaryFor = ( + attributes: ScheduleAttributes | undefined, + now: Date, +): ScanScheduleSummary | undefined => + attributes && attributes.scan_enabled && isScheduleConfigured(attributes) + ? buildProviderScheduleSummary(attributes, now) + : undefined; + const enrichProviders = ( - providersResponse?: ProvidersApiResponse, - scheduledProviderIds?: Set, + providersResponse: ProvidersApiResponse | undefined, + scanScheduledProviderIds: Set, + schedulesByProviderId: Record, ): ProvidersProviderRow[] => { const providerGroupLookup = createProviderGroupLookup(providersResponse); + const now = new Date(); - return (providersResponse?.data ?? []).map((provider) => ({ - ...provider, - rowType: PROVIDERS_ROW_TYPE.PROVIDER, - groupNames: - provider.relationships.provider_groups.data.map( - (providerGroup: { id: string }) => - providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", - ) ?? [], - hasSchedule: scheduledProviderIds?.has(provider.id) ?? false, - })); + return (providersResponse?.data ?? []).map((provider) => { + const scheduleSummary = buildProviderScheduleSummaryFor( + schedulesByProviderId[provider.id], + now, + ); + + return { + ...provider, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + groupNames: + provider.relationships.provider_groups.data.map( + (providerGroup: { id: string }) => + providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", + ) ?? [], + // A fired scheduled scan OR a configured schedule that hasn't fired yet. + hasSchedule: + scanScheduledProviderIds.has(provider.id) || + scheduleSummary !== undefined, + scheduleSummary, + }; + }); }; const createOrganizationRow = ({ @@ -453,6 +484,7 @@ export async function loadProvidersAccountsViewData({ providersResponse, allProvidersResponse, scansResponse, + schedulesResponse, organizationsResponse, organizationUnitsResponse, ] = await Promise.all([ @@ -468,7 +500,7 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), - // Fetch active scheduled scans to determine daily schedule per provider + // Fetch active scheduled scans to flag providers whose schedule has fired. resolveActionResult( getScans({ pageSize: 500, @@ -478,6 +510,9 @@ export async function loadProvidersAccountsViewData({ }, }), ), + // Fetch configured schedules to also flag providers whose schedule has not + // fired yet (best-effort: absent in OSS, where the helper yields no ids). + resolveActionResult(getSchedules()), isCloud ? listOrganizationsSafe() : Promise.resolve(emptyOrganizationsResponse), @@ -486,13 +521,18 @@ export async function loadProvidersAccountsViewData({ : Promise.resolve(emptyOrganizationUnitsResponse), ]); - const scheduledProviderIds = buildScheduledProviderIds( + const scanScheduledProviderIds = buildScheduledProviderIds( scansResponse?.data ?? [], ); + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse); const orgs = organizationsResponse?.data ?? []; const ous = organizationUnitsResponse?.data ?? []; - const providers = enrichProviders(providersResponse, scheduledProviderIds); + const providers = enrichProviders( + providersResponse, + scanScheduledProviderIds, + schedulesByProviderId, + ); const rows = buildProvidersTableRows({ isCloud, diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 4bde757224..d945380f93 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,9 +3,12 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules } from "@/actions/schedules"; import { auth } from "@/auth.config"; import { PageReady } from "@/components/onboarding"; import { + appendPendingScheduleRowsToPage, + getProviderIdsFromScans, getScanJobsTab, getScanJobsTabFilters, getScanJobsUserFilters, @@ -15,14 +18,43 @@ import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-emp import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/ui"; +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + isScheduleConfigured, +} from "@/lib/schedules"; import { ProviderProps, SCAN_JOBS_TAB, + SCAN_TRIGGER, ScanProps, SearchParamsProps, } from "@/types"; const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; +// Pending schedule rows are derived from provider schedules, but must honor the +// same provider filters as real scan rows. Keep these filter keys typed locally +// without narrowing the global SearchParamsProps shape used by Next pages. +const PENDING_ROW_PROVIDER_FILTER = { + PROVIDER_UID_IN: "provider_uid__in", + PROVIDER_UID: "provider_uid", + PROVIDER_TYPE_IN: "provider_type__in", + PROVIDER_TYPE: "provider_type", +} as const; + +type PendingRowProviderFilter = + (typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER]; +type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`; + +const PROVIDER_UID_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`, +] as const satisfies ReadonlyArray; + +const PROVIDER_TYPE_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`, +] as const satisfies ReadonlyArray; const getFilterSearchQuery = ( filters: Record, @@ -33,6 +65,47 @@ const getFilterSearchQuery = ( return value ?? ""; }; +const parseCsvParam = (value?: string | string[]): string[] => { + const rawValue = Array.isArray(value) ? value.join(",") : value; + if (!rawValue) return []; + + return rawValue + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; + +const getFirstSearchParam = ( + searchParams: SearchParamsProps, + keys: ReadonlyArray, +): string | string[] | undefined => { + for (const key of keys) { + const value = searchParams[key]; + if (value !== undefined) return value; + } + + return undefined; +}; + +/** Applies the table's provider filters to synthetic pending-schedule rows. */ +const filterProvidersForPendingRows = ( + providers: ProviderProps[], + searchParams: SearchParamsProps, +): ProviderProps[] => { + const uids = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS), + ); + const types = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS), + ); + + return providers.filter( + (provider) => + (uids.length === 0 || uids.includes(provider.attributes.uid)) && + (types.length === 0 || types.includes(provider.attributes.provider)), + ); +}; + const getActiveScanCount = async ( searchParams: SearchParamsProps, ): Promise => { @@ -53,6 +126,40 @@ const getActiveScanCount = async ( return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0; }; +/** + * A provider can already have a real scheduled scan on a different page. + * Current-page rows are not enough to decide whether a schedule needs a + * synthetic Pending row, so fetch all scheduled scan provider ids when the + * backend paginated result is larger than the current slice. + */ +const getCoveredScheduledProviderIds = async ({ + currentScans, + realScanCount, + query, + filters, +}: { + currentScans: ScanProps[]; + realScanCount: number; + query: string; + filters: Record; +}): Promise> => { + if (realScanCount === 0 || currentScans.length === realScanCount) { + return getProviderIdsFromScans(currentScans); + } + + const allScheduledScansData = await getScans({ + query, + page: 1, + pageSize: realScanCount, + filters, + include: "provider", + }); + + return getProviderIdsFromScans( + (allScheduledScansData?.data ?? []) as ScanProps[], + ); +}; + export default async function Scans({ searchParams, }: { @@ -123,7 +230,10 @@ export default async function Scans({ /> } > - + )} @@ -133,8 +243,10 @@ export default async function Scans({ const SSRDataTableScans = async ({ searchParams, + providers, }: { searchParams: SearchParamsProps; + providers: ProviderProps[]; }) => { const tab = getScanJobsTab(searchParams.tab); @@ -170,7 +282,7 @@ const SSRDataTableScans = async ({ const included = scansData?.included; const meta = scansData && "meta" in scansData ? scansData.meta : undefined; - const expandedScansData = + const expandedScansData: ScanProps[] = scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; @@ -191,10 +303,60 @@ const SSRDataTableScans = async ({ }; }) || []; + const needsSchedules = + tab === SCAN_JOBS_TAB.SCHEDULED || + expandedScansData.some( + (scan) => scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED, + ); + const schedulesResult = needsSchedules ? await getSchedules() : null; + + // Schedules are keyed by provider id so real scheduled scan rows can display + // cadence/next-run info, and schedule-only providers can become Pending rows. + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResult); + + const scansWithSchedule = expandedScansData.map((scan) => { + if (scan.attributes.trigger !== SCAN_TRIGGER.SCHEDULED) return scan; + + const providerId = scan.relationships?.provider?.data?.id; + const schedule = providerId ? schedulesByProviderId[providerId] : undefined; + if (!schedule || !isScheduleConfigured(schedule)) return scan; + + return { + ...scan, + providerSchedule: buildProviderScheduleSummary(schedule, new Date()), + }; + }); + + let tableData = scansWithSchedule; + let tableMeta = meta; + if (tab === SCAN_JOBS_TAB.SCHEDULED) { + // The backend paginates real scans only. Pending schedule rows are generated + // client-side, so reconcile both sources before passing data/meta to the table. + const coveredProviderIds = await getCoveredScheduledProviderIds({ + currentScans: scansWithSchedule, + realScanCount: meta?.pagination?.count ?? scansWithSchedule.length, + query, + filters, + }); + const scheduledTable = appendPendingScheduleRowsToPage({ + scans: scansWithSchedule, + meta, + page, + pageSize, + providers: filterProvidersForPendingRows(providers, searchParams), + schedulesByProviderId, + coveredProviderIds, + now: new Date(), + }); + + tableData = scheduledTable.data; + tableMeta = scheduledTable.meta; + } + return ( diff --git a/ui/components/providers/link-to-scans.tsx b/ui/components/providers/link-to-scans.tsx index 09f06a7fd8..fee1ec407d 100644 --- a/ui/components/providers/link-to-scans.tsx +++ b/ui/components/providers/link-to-scans.tsx @@ -1,25 +1,29 @@ "use client"; -import Link from "next/link"; - -import { Button } from "@/components/shadcn"; +import { StackedCell } from "@/components/shadcn"; +import { formatLocalTimeWithZone } from "@/lib/date-utils"; +import type { ScanScheduleSummary } from "@/types/scans"; interface LinkToScansProps { hasSchedule: boolean; - providerUid?: string; + schedule?: ScanScheduleSummary; } -export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => { +// Matches the scans table Schedule column: cadence on top, next-run local time +// underneath. Falls back to a plain label when the cadence is unknown. +export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => { + if (schedule) { + return ( + + ); + } + return ( -
- - {hasSchedule ? "Daily" : "None"} - - -
+ + {hasSchedule ? "Daily" : "None"} + ); }; diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index 75949c2ae9..f7ef851a0c 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -1,8 +1,9 @@ -import { act, render, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; 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 { OrgLaunchScan } from "./org-launch-scan"; @@ -76,4 +77,72 @@ describe("OrgLaunchScan", () => { 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(); + + render( + , + ); + + // Then + expect( + screen.getByText(/scheduled scans are not available for trial accounts/i), + ).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + + // When + await waitFor(() => { + expect(onFooterChange).toHaveBeenCalled(); + }); + 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(); + + render( + , + ); + + // When + await waitFor(() => { + expect(onFooterChange).toHaveBeenCalled(); + }); + const footerConfig = onFooterChange.mock.calls.at(-1)?.[0]; + await act(async () => { + footerConfig.onAction?.(); + }); + + // Then + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + expect(footerConfig.actionDisabled).toBe(true); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index 535d4186ba..6aede38b81 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -21,12 +21,23 @@ import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; import { ToastAction, useToast } from "@/components/ui"; import { useOrgSetupStore } from "@/store/organizations/store"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, +} from "@/types/schedules"; import { TREE_ITEM_STATUS } from "@/types/tree"; interface OrgLaunchScanProps { onClose: () => void; onBack: () => void; onFooterChange: (config: WizardFooterConfig) => void; + /** + * Schedule capability override. Prowler Cloud passes MANUAL_ONLY for trial + * tenants so organization onboarding cannot create recurring schedules. + */ + capability?: ScanScheduleCapability; + /** Cloud-only manual scan quota signal. */ + isScanLimitReached?: boolean; } const SCAN_SCHEDULE = { @@ -40,6 +51,8 @@ export function OrgLaunchScan({ onClose, onBack, onFooterChange, + capability, + isScanLimitReached = false, }: OrgLaunchScanProps) { const router = useRouter(); const { toast } = useToast(); @@ -51,13 +64,21 @@ export function OrgLaunchScan({ SCAN_SCHEDULE.DAILY, ); const launchActionRef = useRef<() => void>(() => {}); + const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const effectiveScheduleOption = isManualOnly + ? SCAN_SCHEDULE.SINGLE + : scheduleOption; const handleLaunchScan = async () => { + if (isManualOnly && isScanLimitReached) { + return; + } + setIsLaunching(true); const result = await launchOrganizationScans( createdProviderIds, - scheduleOption, + effectiveScheduleOption, ); const successCount = result.successCount; @@ -69,7 +90,7 @@ export function OrgLaunchScan({ toast({ title: "Scan Launched", description: - scheduleOption === SCAN_SCHEDULE.DAILY + effectiveScheduleOption === SCAN_SCHEDULE.DAILY ? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.` : `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`, action: ( @@ -92,13 +113,23 @@ export function OrgLaunchScan({ onBack, showAction: true, actionLabel: "Launch scan", - actionDisabled: isLaunching || createdProviderIds.length === 0, + actionDisabled: + isLaunching || + createdProviderIds.length === 0 || + (isManualOnly && isScanLimitReached), actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, onAction: () => { launchActionRef.current(); }, }); - }, [createdProviderIds.length, isLaunching, onBack, onFooterChange]); + }, [ + createdProviderIds.length, + isLaunching, + isManualOnly, + isScanLimitReached, + onBack, + onFooterChange, + ]); return (
@@ -146,30 +177,45 @@ export function OrgLaunchScan({

)} -
-

- Select a Prowler scan schedule for these accounts. -

- -
+ {isManualOnly ? ( +
+

+ Scheduled scans are not available for trial accounts. These + accounts will run a one-time manual scan now. +

+ {isScanLimitReached && ( +

+ You have reached your scan limit, so additional scans are not + available right now. +

+ )} +
+ ) : ( +
+

+ Select a Prowler scan schedule for these accounts. +

+ +
+ )}
)} diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index a7b7702f0e..d1ff22a958 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -29,6 +29,7 @@ import { } from "@/lib/tours/use-driver-tour"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +import type { ScanScheduleCapability } from "@/types/schedules"; const addProviderFlow = getFlowById("add-provider")!; @@ -51,6 +52,9 @@ interface ProvidersAccountsViewProps { metadata?: MetaDataProps; providers: ProviderProps[]; rows: ProvidersTableRow[]; + /** Cloud overlay seam for provider-creation scan launch. */ + scanScheduleCapability?: ScanScheduleCapability; + isScanLimitReached?: boolean; } export function ProvidersAccountsView({ @@ -59,6 +63,8 @@ export function ProvidersAccountsView({ metadata, providers, rows, + scanScheduleCapability, + isScanLimitReached, }: ProvidersAccountsViewProps) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -157,6 +163,8 @@ export function ProvidersAccountsView({ initialData={providerWizardInitialData} orgInitialData={orgWizardInitialData} refreshOnClose={false} + scanScheduleCapability={scanScheduleCapability} + isScanLimitReached={isScanLimitReached} /> ); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index ec5766f491..7a0daf9b40 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -1,18 +1,13 @@ "use client"; import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; -import { - Building2, - FolderTree, - ShieldAlert, - ShieldCheck, - ShieldOff, -} from "lucide-react"; +import { Building2, FolderTree } from "lucide-react"; import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { Badge } from "@/components/shadcn"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { DateWithTime, EntityInfo } from "@/components/ui/entities"; @@ -47,27 +42,24 @@ const OrganizationIcon = ({ groupKind }: { groupKind: string }) => { const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => { if (connected === true) { return ( -
- - Connected -
+ + Connected + ); } if (connected === false) { return ( -
- - Connection failed -
+ + Connection failed + ); } return ( -
- - Not connected -
+ + Not connected + ); }; @@ -259,7 +251,7 @@ export function getColumnProviders( return ( ); }, diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index d72874af31..dc0aeadf47 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -1,7 +1,7 @@ import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { @@ -10,7 +10,17 @@ import { ProvidersTableRow, } from "@/types/providers-table"; -const checkConnectionProviderMock = vi.hoisted(() => vi.fn()); +const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted( + () => ({ + checkConnectionProviderMock: vi.fn(), + getScheduleMock: vi.fn(), + pushMock: vi.fn(), + }), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); vi.mock("@/actions/organizations/organizations", () => ({ updateOrganizationName: vi.fn(), @@ -20,6 +30,10 @@ vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider: checkConnectionProviderMock, })); +vi.mock("@/actions/schedules", () => ({ + getSchedule: getScheduleMock, +})); + vi.mock("../forms/delete-form", () => ({ DeleteForm: () => null, })); @@ -32,6 +46,26 @@ vi.mock("../forms/edit-name-form", () => ({ EditNameForm: () => null, })); +vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({ + EDIT_SCAN_SCHEDULE_STATE: { + LOADING: "loading", + LOADED: "loaded", + ERROR: "error", + }, + EditScanScheduleModal: ({ + open, + provider, + }: { + open: boolean; + provider?: { providerId: string }; + }) => + open ? ( +
+ Editing schedule for {provider?.providerId} +
+ ) : null, +})); + vi.mock("@/components/ui", () => ({ useToast: () => ({ toast: vi.fn() }), })); @@ -143,6 +177,23 @@ const createOuRow = () => }) as unknown as Row; describe("DataTableRowActions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + getScheduleMock.mockResolvedValue({ + data: { + type: "schedules", + id: "provider-1", + attributes: { scan_hour: null }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + }); + }); + it("renders Add Credentials for provider rows without credentials", async () => { // Given const user = userEvent.setup(); @@ -163,12 +214,97 @@ describe("DataTableRowActions", () => { // Then expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); + // Advanced schedule editing is gated to Prowler Cloud subscribed accounts. + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); expect(screen.getByText("Add Credentials")).toBeInTheDocument(); expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("Delete Provider")).toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); + it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("View Scan Jobs")); + + // Then: navigates with the key the scans filter bar binds to + // (provider_uid__in), URL-encoded, so the provider is pre-selected. + expect(pushMock).toHaveBeenCalledWith( + "/scans?filter%5Bprovider_uid__in%5D=111111111111", + ); + }); + + it("URL-encodes provider UIDs that contain unsafe characters (e.g. GitHub)", async () => { + // Given a GitHub provider whose UID is a URL. + const user = userEvent.setup(); + const row = createRow(true); + ( + row.original as unknown as { attributes: { uid: string } } + ).attributes.uid = "https://github.com/prowler-cloud/prowler"; + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("View Scan Jobs")); + + // Then the ':' and '/' are encoded instead of leaking into the URL raw. + expect(pushMock).toHaveBeenCalledWith( + "/scans?filter%5Bprovider_uid__in%5D=https%3A%2F%2Fgithub.com%2Fprowler-cloud%2Fprowler", + ); + }); + + it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Edit Scan Schedule")); + + // Then + expect( + screen.getByRole("dialog", { name: /edit scan schedule/i }), + ).toHaveTextContent("Editing schedule for provider-1"); + }); + it("renders Update Credentials for provider rows with credentials", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 9b531f272a..44d7198e2e 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -1,16 +1,31 @@ "use client"; import { Row } from "@tanstack/react-table"; -import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react"; +import { + CalendarClock, + KeyRound, + Pencil, + Rocket, + Timer, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { updateProvider } from "@/actions/providers"; +import { getSchedule } from "@/actions/schedules"; import { ORG_WIZARD_INTENT, OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { + EDIT_SCAN_SCHEDULE_STATE, + EditScanScheduleModal, + type EditScanScheduleState, + type ScanScheduleProvider, +} from "@/components/scans/schedule/edit-scan-schedule-modal"; import { ActionDropdown, ActionDropdownDangerZone, @@ -20,6 +35,8 @@ import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { testProviderConnection } from "@/lib/provider-helpers"; +import { getScanScheduleCapability } from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; import { @@ -29,6 +46,11 @@ import { ProvidersOrganizationRow, ProvidersTableRow, } from "@/types/providers-table"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScheduleApiResponse, +} from "@/types/schedules"; import { DeleteForm } from "../forms/delete-form"; import { DeleteOrganizationForm } from "../forms/delete-organization-form"; @@ -46,6 +68,13 @@ interface DataTableRowActionsProps { onClearSelection: () => void; onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; + /** + * Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud + * decision). The prowler-cloud overlay injects a billing-aware capability so + * only subscribed Cloud accounts can open the advanced schedule editor (which + * talks to the new schedule API). + */ + capability?: ScanScheduleCapability; } function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] { @@ -200,11 +229,20 @@ export function DataTableRowActions({ onClearSelection, onOpenProviderWizard, onOpenOrganizationWizard, + capability, }: DataTableRowActionsProps) { + const canEditSchedule = + (capability ?? getScanScheduleCapability(isCloud())) === + SCAN_SCHEDULE_CAPABILITY.ADVANCED; const [isEditOpen, setIsEditOpen] = useState(false); + const [isScheduleOpen, setIsScheduleOpen] = useState(false); + const [scheduleState, setScheduleState] = useState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADING, + }); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [loading, setLoading] = useState(false); const { toast } = useToast(); + const router = useRouter(); const rowData = row.original; const isOrganizationRow = isProvidersOrganizationRow(rowData); @@ -215,6 +253,14 @@ export function DataTableRowActions({ const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; const hasSecret = Boolean(provider?.relationships.secret.data); + const scheduleProvider: ScanScheduleProvider | undefined = provider + ? { + providerId, + providerType, + providerUid, + providerAlias, + } + : undefined; const orgGroupKind = isOrganizationRow ? rowData.groupKind : null; const childTestableIds = isOrganizationRow @@ -283,6 +329,40 @@ export function DataTableRowActions({ await handleBulkTest(childTestableIds); }; + const openScheduleEditor = async () => { + if (!providerId) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: "Provider ID is not available.", + }); + setIsScheduleOpen(true); + return; + } + + setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING }); + setIsScheduleOpen(true); + + const response = (await getSchedule(providerId)) as + | ScheduleApiResponse + | { error?: string }; + + if (!response || ("error" in response && response.error)) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: + response && "error" in response && response.error + ? response.error + : "Failed to load scan schedule.", + }); + return; + } + + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, + schedule: "data" in response ? response.data : null, + }); + }; + // When this row is part of the selection, only show "Test Connection" if (hasSelection && isRowSelected) { const bulkCount = @@ -364,6 +444,12 @@ export function DataTableRowActions({ )} +
setIsEditOpen(true)} /> + } + label="View Scan Jobs" + onSelect={() => { + // Use the same key the scans filter bar binds to + // (`provider_uid__in`) so the provider is pre-selected in the UI, + // and URLSearchParams to encode UIDs that contain URL-unsafe chars + // (e.g. GitHub UIDs like https://github.com/org/repo). + const params = new URLSearchParams({ + "filter[provider_uid__in]": providerUid, + }); + router.push(`/scans?${params.toString()}`); + }} + /> + {canEditSchedule && ( + } + label="Edit Scan Schedule" + onSelect={() => void openScheduleEditor()} + /> + )} } label={hasSecret ? "Update Credentials" : "Add Credentials"} diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 36c5058120..8904b35e43 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -15,6 +15,7 @@ import { PROVIDER_WIZARD_MODE, PROVIDER_WIZARD_STEP, } from "@/types/provider-wizard"; +import type { ScanScheduleCapability } from "@/types/schedules"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { @@ -40,6 +41,10 @@ interface ProviderWizardModalProps { initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; refreshOnClose?: boolean; + /** Cloud overlay seam; defaults to the environment-resolved capability. */ + scanScheduleCapability?: ScanScheduleCapability; + /** Cloud-only manual scan quota signal. */ + isScanLimitReached?: boolean; } export function ProviderWizardModal({ @@ -48,6 +53,8 @@ export function ProviderWizardModal({ initialData, orgInitialData, refreshOnClose, + scanScheduleCapability, + isScanLimitReached, }: ProviderWizardModalProps) { const { backToProviderFlow, @@ -107,234 +114,246 @@ export function ProviderWizardModal({
-
-
- {isProviderFlow ? ( - - ) : ( - - )} -
-
- - {/* Anchors the add-provider tour's final step to the whole right section — - the form inputs AND the footer — so the spotlight covers the Next button - instead of leaving it under the overlay. The popover still pins to the left - of this section, beside the inputs. */} -
-
-
- {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( - { - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); - // Reaching credentials is the tour's handoff point: end it so the - // user continues on their own. No-op off-onboarding. - endActiveTour(); - }} - onSelectOrganizations={openOrganizationsFlow} - onFooterChange={setFooterConfig} - onProviderTypeChange={(providerType) => { - // Picking a type reveals the account-detail inputs. Advance the tour - // to its wizard-body step, pinned beside the form. No-op off-onboarding. - if (providerType) advanceActiveTour(); - setProviderTypeHint(providerType); - }} - /> + {/* Anchors the add-provider tour's final step to the wizard content and + footer, keeping the real form controls clickable under the overlay. */} +
+
+
+ {isProviderFlow ? ( + + ) : ( + setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} - onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)} - onFooterChange={setFooterConfig} - /> - )} - - {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && ( - - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS) - } - onFooterChange={setFooterConfig} - /> - )} - - {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( - setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} - onClose={handleClose} - onFooterChange={setFooterConfig} - /> - )} - - {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); - }} - onFooterChange={setFooterConfig} - onPhaseChange={setOrgSetupPhase} - initialPhase={orgSetupPhase} - initialValues={ - orgInitialData - ? { - organizationName: orgInitialData.organizationName, - awsOrgId: orgInitialData.externalId, - } - : undefined - } - intent={orgInitialData?.intent} - /> - )} - - {!isProviderFlow && - orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS); - }} - onNext={() => { - setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); - }} - onSkip={() => { - setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); - }} - onFooterChange={setFooterConfig} - /> - )} - - {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); - }} - onFooterChange={setFooterConfig} - /> - )} - - {/* Sentinel element for IntersectionObserver scroll detection */} -
-
- - {showScrollHint && ( -
-
-
- - Scroll to see more - -
-
+ /> )}
- {/* Inside the highlighted right section so the tour spotlight covers the Next - button (no longer dimmed by the overlay). Aligned to the form width so Next - stays at the right edge. */} - {(resolvedFooterConfig.showBack || - resolvedFooterConfig.showSecondaryAction || - resolvedFooterConfig.showAction) && ( -
-
-
- {resolvedFooterConfig.showBack && ( - - )} -
-
- {resolvedFooterConfig.showSecondaryAction && ( - +
+ +
+
+
+ {isProviderFlow && + currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( + { + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + // Reaching credentials is the tour's handoff point: end it so the + // user continues on their own. No-op off-onboarding. + endActiveTour(); + }} + onSelectOrganizations={openOrganizationsFlow} + onFooterChange={setFooterConfig} + onProviderTypeChange={(providerType) => { + // Picking a type reveals the account-detail inputs. Advance the tour + // to its wizard-body step, pinned beside the form. No-op off-onboarding. + if (providerType) advanceActiveTour(); + setProviderTypeHint(providerType); + }} + /> )} - {resolvedFooterConfig.showAction && ( - + intent={orgInitialData?.intent} + /> )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS); + }} + onNext={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onSkip={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + }} + onFooterChange={setFooterConfig} + capability={scanScheduleCapability} + isScanLimitReached={isScanLimitReached} + /> + )} + + {/* Sentinel element for IntersectionObserver scroll detection */} +
+
+ + {showScrollHint && ( +
+
+
+ + Scroll to see more + +
+ )} +
+
+
+ + {(resolvedFooterConfig.showBack || + resolvedFooterConfig.showSecondaryAction || + resolvedFooterConfig.showAction) && ( +
+
+
+ {resolvedFooterConfig.showBack && ( + + )} +
+
+ {resolvedFooterConfig.showSecondaryAction && ( + + )} + + {resolvedFooterConfig.showAction && ( + + )}
- )} -
+
+ )}
); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index 1f33955849..cfa8e2e63d 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -1,21 +1,32 @@ -import { act, render, waitFor } from "@testing-library/react"; +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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SCAN_JOBS_TAB } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + SCHEDULE_FREQUENCY, +} from "@/types/schedules"; import { LaunchStep } from "./launch-step"; -const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({ - scheduleDailyMock: vi.fn(), - scanOnDemandMock: vi.fn(), - toastMock: vi.fn(), -})); +const { scanOnDemandMock, scheduleDailyMock, toastMock, updateScheduleMock } = + vi.hoisted(() => ({ + scanOnDemandMock: vi.fn(), + scheduleDailyMock: vi.fn(), + toastMock: vi.fn(), + updateScheduleMock: vi.fn(), + })); vi.mock("@/actions/scans", () => ({ - scheduleDaily: scheduleDailyMock, scanOnDemand: scanOnDemandMock, + scheduleDaily: scheduleDailyMock, +})); + +vi.mock("@/actions/schedules", () => ({ + updateSchedule: updateScheduleMock, })); vi.mock("@/components/ui", () => ({ @@ -27,64 +38,333 @@ vi.mock("@/components/ui", () => ({ }), })); +const seedConnectedProvider = () => { + useProviderWizardStore.setState({ + providerId: "provider-1", + providerType: "gcp", + providerUid: "project-123", + mode: "add", + }); +}; + +const lastFooterConfig = (onFooterChange: ReturnType) => + onFooterChange.mock.calls.at(-1)?.[0]; + describe("LaunchStep", () => { beforeEach(() => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), + } as Intl.DateTimeFormat); sessionStorage.clear(); localStorage.clear(); + updateScheduleMock.mockReset(); scheduleDailyMock.mockReset(); scanOnDemandMock.mockReset(); toastMock.mockReset(); useProviderWizardStore.getState().reset(); }); - it("launches a daily scan and shows toast", async () => { - // Given - const onClose = vi.fn(); - const onFooterChange = vi.fn(); - useProviderWizardStore.setState({ - providerId: "provider-1", - providerType: "gcp", - providerUid: "project-123", - mode: "add", + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("Prowler OSS (non-Cloud)", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); - scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } }); + it("defaults to run now and locks schedule mode outside Cloud", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); - render( - , - ); + render( + , + ); - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); + // Then + expect(screen.getByText("Account Connected!")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeDisabled(); + expect( + screen.queryByRole("combobox", { name: /repeats/i }), + ).not.toBeInTheDocument(); + + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); }); - // When - const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - initialFooterConfig.onAction?.(); + it("launches only an on-demand scan and never creates a legacy daily schedule", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData; + expect(sentFormData.get("providerId")).toBe("provider-1"); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("Prowler Cloud subscribed", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } }); }); - // Then - await waitFor(() => { - expect(scheduleDailyMock).toHaveBeenCalledTimes(1); + it("defaults to schedule mode and saves through the new schedule API", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then advanced cadence selector is enabled + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeChecked(); + expect( + screen.getByRole("combobox", { name: /repeats/i }), + ).not.toBeDisabled(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).toHaveBeenCalledWith( + "provider-1", + expect.objectContaining({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: expect.any(Number), + scan_timezone: "Europe/Madrid", + scan_day_of_week: null, + scan_day_of_month: null, + }), + ); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action.props.children.props.href).toBe( + `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`, + ); }); - const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData; - expect(sentFormData.get("providerId")).toBe("provider-1"); - expect(onClose).toHaveBeenCalledTimes(1); - expect(scanOnDemandMock).not.toHaveBeenCalled(); - expect(toastMock).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Scan Launched", - }), - ); - const toastPayload = toastMock.mock.calls[0]?.[0]; - expect(toastPayload.action.props.children.props.href).toBe( - `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, - ); + it("also launches an on-demand scan when the checkbox is checked", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(scanOnDemandMock).toHaveBeenCalledTimes(1); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action.props.children.props.href).toBe( + `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, + ); + }); + + it("launches only an on-demand scan when run now is selected", async () => { + // Given + const user = userEvent.setup(); + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("radio", { + name: "Run now", + }), + ); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Launch scan", + ), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not launch an initial scan when the schedule save fails", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + updateScheduleMock.mockResolvedValue({ error: "Schedule failed" }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ title: "Unable to save scan schedule" }), + ); + }); + }); + + describe("Prowler Cloud trial/onboarding (manual scan only)", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then no schedule cadence selector is rendered + expect( + screen.queryByRole("combobox", { name: /repeats/i }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeDisabled(); + expect( + screen.getByText(/scheduled scans are not available/i), + ).toBeInTheDocument(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(false); + // The action launches a scan here, so it must not be labeled "Save". + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("disables the action and shows the limit copy when over limit", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + }); }); }); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 1e63f6700d..db42d5be0e 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -1,21 +1,42 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; -import { scanOnDemand, scheduleDaily } from "@/actions/scans"; +import { scanOnDemand } from "@/actions/scans"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/shadcn/select/select"; + SAVE_SCHEDULE_STATUS, + saveScheduleWithInitialScan, +} from "@/components/scans/schedule/save-schedule"; +import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; +import { Field, FieldLabel } from "@/components/shadcn"; +import { + RadioGroup, + RadioGroupItem, +} from "@/components/shadcn/radio-group/radio-group"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; +import { + CloudFeatureBadge, + CloudFeatureBadgeLink, +} from "@/components/shared/cloud-feature-badge"; import { ToastAction, useToast } from "@/components/ui"; +import { EntityInfo } from "@/components/ui/entities"; +import { + getScanScheduleCapability, + getScheduleFormDefaults, + scheduleFormSchema, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SCAN_JOBS_TAB } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScheduleFormValues, +} from "@/types/schedules"; import { TREE_ITEM_STATUS } from "@/types/tree"; import { @@ -23,51 +44,94 @@ import { WizardFooterConfig, } from "./footer-controls"; -const SCAN_SCHEDULE = { - DAILY: "daily", - SINGLE: "single", +const LAUNCH_MODE = { + NOW: "now", + SCHEDULE: "schedule", } as const; -type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE]; +type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE]; interface LaunchStepProps { onBack: () => void; onClose: () => void; onFooterChange: (config: WizardFooterConfig) => void; + /** + * Schedule capability override. Absent in Prowler OSS (defaults to a + * Cloud-vs-non-Cloud decision). The prowler-cloud overlay computes a + * billing-aware capability and injects it here so trial/onboarding accounts + * are limited to manual scans. + */ + capability?: ScanScheduleCapability; + /** + * When true, the manual scan action is disabled (account scan quota reached). + * Cloud-only signal; never set in OSS. + */ + isScanLimitReached?: boolean; } export function LaunchStep({ onBack, onClose, onFooterChange, + capability: capabilityProp, + isScanLimitReached = false, }: LaunchStepProps) { const { toast } = useToast(); - const { providerId } = useProviderWizardStore(); + const { providerAlias, providerId, providerType, providerUid } = + useProviderWizardStore(); + const capability = capabilityProp ?? getScanScheduleCapability(isCloud()); + const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; const [isLaunching, setIsLaunching] = useState(false); - const [scheduleOption, setScheduleOption] = useState( - SCAN_SCHEDULE.DAILY, + const [mode, setMode] = useState( + isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW, ); - const launchActionRef = useRef<() => void>(() => {}); + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormDefaults(), + }); - const handleLaunchScan = async () => { - if (!providerId) { + const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached; + const isActionBlocked = isLaunching || !providerId || isLimitBlocked; + const launchInitialScan = useWatch({ + control: form.control, + name: "launchInitialScan", + }); + + const actionLabel = (() => { + if (!isScheduleMode) { + return isLaunching ? "Launching scan..." : "Launch scan"; + } + + if (isLaunching) { + return launchInitialScan ? "Saving and launching..." : "Saving..."; + } + + return launchInitialScan ? "Save and launch scan" : "Save"; + })(); + + const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => { + if (!providerId) return null; + const formData = new FormData(); + formData.set("providerId", providerId); + return scanOnDemand(formData); + }; + + const handleManualScan = async () => { + if (isScanLimitReached) { return; } setIsLaunching(true); - const formData = new FormData(); - formData.set("providerId", providerId); - const result = - scheduleOption === SCAN_SCHEDULE.DAILY - ? await scheduleDaily(formData) - : await scanOnDemand(formData); + const scanResult = await launchOnDemandScan(); - if (result?.error) { + if (scanResult?.error) { setIsLaunching(false); toast({ variant: "destructive", title: "Unable to launch scan", - description: String(result.error), + description: String(scanResult.error), }); return; } @@ -75,11 +139,8 @@ export function LaunchStep({ setIsLaunching(false); onClose(); toast({ - title: "Scan Launched", - description: - scheduleOption === SCAN_SCHEDULE.DAILY - ? "Daily scan scheduled successfully." - : "Single scan launched successfully.", + title: "Scan launched", + description: "The scan was launched successfully.", action: ( Go to scans @@ -88,8 +149,69 @@ export function LaunchStep({ }); }; - launchActionRef.current = () => { - void handleLaunchScan(); + const handleSaveSchedule = form.handleSubmit(async (values) => { + if (!providerId) { + return; + } + + setIsLaunching(true); + + const result = await saveScheduleWithInitialScan({ + providerId, + values, + useLegacyDaily: !isAdvanced, + }); + + setIsLaunching(false); + + if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { + toast({ + variant: "destructive", + title: "Unable to save scan schedule", + description: result.message, + }); + return; + } + + onClose(); + + const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED; + const targetTab = launched ? SCAN_JOBS_TAB.ACTIVE : SCAN_JOBS_TAB.SCHEDULED; + const goToScans = ( + + Go to scans + + ); + + if (result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED) { + toast({ + title: "Scan schedule saved", + description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`, + action: goToScans, + }); + return; + } + + toast({ + title: launched + ? "Scan schedule saved and initial scan launched" + : "Scan schedule saved", + description: launched + ? "The schedule was saved and the initial scan was launched." + : "The scan schedule was saved successfully.", + action: goToScans, + }); + }); + + // Keep the latest action handler in a ref so the footer (synced via effect) + // always invokes the current closure without re-running on every render. + const actionRef = useRef<() => void>(() => {}); + actionRef.current = () => { + if (!isScheduleMode) { + void handleManualScan(); + return; + } + void handleSaveSchedule(); }; useEffect(() => { @@ -99,21 +221,30 @@ export function LaunchStep({ backDisabled: isLaunching, onBack, showAction: true, - actionLabel: isLaunching ? "Launching scans..." : "Launch scan", - actionDisabled: isLaunching || !providerId, + actionLabel, + actionDisabled: isActionBlocked, actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onAction: () => { - launchActionRef.current(); - }, + onAction: () => actionRef.current(), }); - }, [isLaunching, onBack, onFooterChange, providerId]); + }, [ + isActionBlocked, + isLaunching, + actionLabel, + isScheduleMode, + launchInitialScan, + mode, + onBack, + onFooterChange, + ]); if (isLaunching) { return (
-

Launching scans...

+

+ {!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."} +

); @@ -121,13 +252,21 @@ export function LaunchStep({ return (
+ {(providerId || providerUid) && ( + + )} +
-

Connection validated!

+

Account Connected!

- Choose how you want to launch scans for this provider. + Your account is connected to Prowler and ready to Scan!

{!providerId && ( @@ -136,28 +275,57 @@ export function LaunchStep({

)} -
-

Scan schedule

- -
+ + + + + + {!isAdvanced && ( +

+ Scheduled scans are not available for this account. Run now to get + immediate findings. +

+ )} + + {isLimitBlocked && ( +

+ You have reached your scan limit, so additional scans are not + available right now. +

+ )} + + {isScheduleMode && ( + + )}
); } diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index c5ec901631..97e18dbcae 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -3,13 +3,35 @@ import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { refreshMock, scanOnDemandMock, searchParamsValue, toastMock } = - vi.hoisted(() => ({ - refreshMock: vi.fn(), - scanOnDemandMock: vi.fn(), - searchParamsValue: { current: "" }, - toastMock: vi.fn(), - })); +const { + getScheduleMock, + refreshMock, + scanOnDemandMock, + scheduleDailyMock, + searchParamsValue, + toastMock, + updateScheduleMock, +} = vi.hoisted(() => ({ + getScheduleMock: vi.fn(), + refreshMock: vi.fn(), + scanOnDemandMock: vi.fn(), + scheduleDailyMock: vi.fn(), + searchParamsValue: { current: "" }, + toastMock: vi.fn(), + updateScheduleMock: vi.fn(), +})); + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(globalThis, "ResizeObserver", { + writable: true, + configurable: true, + value: ResizeObserverMock, +}); vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -20,6 +42,12 @@ vi.mock("next/navigation", () => ({ vi.mock("@/actions/scans", () => ({ scanOnDemand: scanOnDemandMock, + scheduleDaily: scheduleDailyMock, +})); + +vi.mock("@/actions/schedules", () => ({ + getSchedule: getScheduleMock, + updateSchedule: updateScheduleMock, })); vi.mock("@/components/ui/toast", () => ({ @@ -95,6 +123,8 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ ), })); +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; + import { LaunchScanModal } from "./launch-scan-modal"; const provider = { @@ -303,4 +333,121 @@ describe("LaunchScanModal", () => { expect(refreshMock).not.toHaveBeenCalled(); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + + describe("schedule mode", () => { + const weeklyScheduleResponse = { + data: { + type: "schedules", + id: provider.id, + attributes: { + scan_enabled: true, + scan_frequency: "WEEKLY", + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: 1, + scan_day_of_month: null, + }, + }, + }; + + beforeEach(() => { + getScheduleMock.mockResolvedValue(weeklyScheduleResponse); + updateScheduleMock.mockResolvedValue({ data: { id: provider.id } }); + }); + + const renderAdvanced = () => + render( + , + ); + + it("prefills the provider schedule and saves it through the new API", async () => { + const user = userEvent.setup(); + renderAdvanced(); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("radio", { name: "On a schedule" })); + + await waitFor(() => + expect(getScheduleMock).toHaveBeenCalledWith(provider.id), + ); + + await user.click( + await screen.findByRole("button", { name: /save schedule/i }), + ); + + // The payload carries the fetched WEEKLY schedule, proving the prefill. + await waitFor(() => + expect(updateScheduleMock).toHaveBeenCalledWith( + provider.id, + expect.objectContaining({ + scan_enabled: true, + scan_frequency: "WEEKLY", + scan_hour: 9, + scan_day_of_week: 1, + }), + ), + ); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ title: "Scan schedule saved" }), + ); + }); + + it("launches the initial scan when the checkbox is checked", async () => { + const user = userEvent.setup(); + renderAdvanced(); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("radio", { name: "On a schedule" })); + await user.click( + await screen.findByLabelText( + "Launch an initial scan now for immediate findings", + ), + ); + await user.click(screen.getByRole("button", { name: /save schedule/i })); + + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalled()); + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Scan schedule saved and initial scan launched", + }), + ); + }); + + it("locks schedule mode outside ADVANCED (OSS default)", () => { + render( + , + ); + + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeDisabled(); + expect(getScheduleMock).not.toHaveBeenCalled(); + }); + + it("hides the mode selector and blocks over-limit accounts in MANUAL_ONLY", () => { + render( + , + ); + + expect(screen.queryByRole("radio")).not.toBeInTheDocument(); + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /launch scan/i }), + ).toBeDisabled(); + }); + }); }); diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx index dd4b049c87..387f2a1f9e 100644 --- a/ui/components/scans/launch-scan-modal.tsx +++ b/ui/components/scans/launch-scan-modal.tsx @@ -1,23 +1,48 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { CloudCog, Rocket } from "lucide-react"; +import { CloudCog, Loader2, Rocket } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { useForm } from "react-hook-form"; +import { useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { scanOnDemand } from "@/actions/scans"; +import { getSchedule } from "@/actions/schedules"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; +import { + RadioGroup, + RadioGroupItem, +} from "@/components/shadcn/radio-group/radio-group"; +import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { FormButtons } from "@/components/ui/form"; import { toast, ToastAction } from "@/components/ui/toast"; +import { + getScanScheduleCapability, + getScheduleFormDefaults, + getScheduleFormValues, + scheduleFormSchema, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { SCAN_JOBS_TAB } from "@/types"; import type { ProviderProps } from "@/types/providers"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScheduleApiResponse, + type ScheduleFormValues, +} from "@/types/schedules"; import { scanAliasSchema } from "./scan-alias-validation"; import { getScanJobsTab } from "./scans.utils"; +import { + SAVE_SCHEDULE_STATUS, + saveScheduleWithInitialScan, +} from "./schedule/save-schedule"; +import { ScanScheduleFields } from "./schedule/scan-schedule-fields"; const launchScanSchema = z.object({ providerId: z.string().min(1, "Select a provider to launch a scan."), @@ -26,33 +51,112 @@ const launchScanSchema = z.object({ type LaunchScanFormValues = z.infer; +const LAUNCH_MODE = { + NOW: "now", + SCHEDULE: "schedule", +} as const; + +type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE]; + +const SCHEDULE_LOAD_STATE = { + IDLE: "idle", + LOADING: "loading", + LOADED: "loaded", + ERROR: "error", +} as const; + +type ScheduleLoadState = + (typeof SCHEDULE_LOAD_STATE)[keyof typeof SCHEDULE_LOAD_STATE]; + interface LaunchScanModalProps { open: boolean; onOpenChange: (open: boolean) => void; providers: ProviderProps[]; + /** Cloud overlay seam; defaults to the environment-resolved capability. */ + capability?: ScanScheduleCapability; + isScanLimitReached?: boolean; } interface LaunchScanFormProps { providers: ProviderProps[]; onClose: () => void; + capability: ScanScheduleCapability; + isScanLimitReached: boolean; } -function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) { +function LaunchScanForm({ + providers, + onClose, + capability, + isScanLimitReached, +}: LaunchScanFormProps) { const router = useRouter(); const searchParams = useSearchParams(); const form = useForm({ resolver: zodResolver(launchScanSchema), defaultValues: { providerId: "", scanAlias: "" }, }); + const scheduleForm = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormDefaults(), + }); + const [mode, setMode] = useState(LAUNCH_MODE.NOW); + const [scheduleLoad, setScheduleLoad] = useState( + SCHEDULE_LOAD_STATE.IDLE, + ); + // Guards against out-of-order responses when switching providers quickly. + const requestedProviderRef = useRef(""); - const providerId = form.watch("providerId"); + const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const isScheduleMode = 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" }); const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined); const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE; const disconnectedProviderIds = providers .filter((provider) => provider.attributes.connection.connected !== true) .map((provider) => provider.id); - const onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => { + const loadSchedule = async (id: string) => { + requestedProviderRef.current = id; + if (!id) { + setScheduleLoad(SCHEDULE_LOAD_STATE.IDLE); + return; + } + + setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING); + const response = (await getSchedule(id)) as + | ScheduleApiResponse + | { error?: string }; + + if (requestedProviderRef.current !== id) return; + + if (!response || ("error" in response && response.error)) { + setScheduleLoad(SCHEDULE_LOAD_STATE.ERROR); + return; + } + + scheduleForm.reset( + getScheduleFormValues( + "data" in response ? response.data?.attributes : null, + ), + ); + setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED); + }; + + const handleProviderChange = (id: string) => { + form.setValue("providerId", id, { shouldValidate: true }); + if (isScheduleMode) void loadSchedule(id); + }; + + const handleModeChange = (nextMode: string) => { + setMode(nextMode as LaunchMode); + if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId); + }; + + const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => { const formData = new FormData(); formData.set("providerId", providerId); const trimmedAlias = scanAlias?.trim(); @@ -87,13 +191,66 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) { router.refresh(); }); + const saveSchedule = async () => { + const providerValid = await form.trigger("providerId"); + if (!providerValid) return; + + await scheduleForm.handleSubmit(async (values) => { + const result = await saveScheduleWithInitialScan({ + providerId: form.getValues("providerId"), + values, + }); + + if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { + form.setError("root", { message: result.message }); + return; + } + + const launched = + result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED; + toast({ + title: launched + ? "Scan schedule saved and initial scan launched" + : "Scan schedule saved", + description: + result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED + ? `The schedule was saved, but the initial scan could not be launched: ${result.message}` + : launched + ? "The schedule was saved and the initial scan was launched." + : "The scan schedule was saved successfully.", + action: ( + + + View schedule + + + ), + }); + onClose(); + router.refresh(); + })(); + }; + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isScheduleMode) { + void saveSchedule(); + return; + } + void launchNow(); + }; + const providerError = form.formState.errors.providerId?.message; const aliasError = form.formState.errors.scanAlias?.message; const rootError = form.formState.errors.root?.message; - const isSubmitting = form.formState.isSubmitting; + 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 +
@@ -108,9 +265,7 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) { providers={providers} disabledValues={disconnectedProviderIds} onBatchChange={(_, values) => - form.setValue("providerId", values.at(-1) ?? "", { - shouldValidate: true, - }) + handleProviderChange(values.at(-1) ?? "") } selectedValues={providerId ? [providerId] : []} closeOnSelect @@ -118,23 +273,93 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) { {providerError && {providerError}} - - Alias (optional) - + Mode + + + + + + )} + + {isLimitBlocked && ( +

+ You have reached your scan limit, so additional scans are not + available right now. +

+ )} + + {!isScheduleMode && ( + + Alias (optional) + + {aliasError && {aliasError}} + + )} + + {isScheduleMode && isScheduleLoading && ( +
+ + Loading scan schedule... +
+ )} + + {isScheduleMode && scheduleLoad === SCHEDULE_LOAD_STATE.ERROR && ( + + Failed to load the current scan schedule. Saving will overwrite it. + + )} + + {isScheduleMode && !isScheduleLoading && ( + - {aliasError && {aliasError}} - + )} {rootError && {rootError}} } /> @@ -145,7 +370,11 @@ export function LaunchScanModal({ open, onOpenChange, providers, + capability, + isScanLimitReached = false, }: LaunchScanModalProps) { + const resolvedCapability = capability ?? getScanScheduleCapability(isCloud()); + return ( onOpenChange(false)} + capability={resolvedCapability} + isScanLimitReached={isScanLimitReached} /> ); diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 9994f8d7bc..4682ff6dfb 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -21,6 +21,7 @@ import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour"; import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; import type { ProviderProps } from "@/types/providers"; +import type { ScanScheduleCapability } from "@/types/schedules"; const viewFirstScanFlow = getFlowById("view-first-scan")!; @@ -34,6 +35,9 @@ interface ScansPageShellProps { hasManageScansPermission: boolean; activeScanCount?: number; children: ReactNode; + /** Cloud overlay seam for the launch-scan modal. */ + scanScheduleCapability?: ScanScheduleCapability; + isScanLimitReached?: boolean; } export function ScansPageShell({ @@ -41,6 +45,8 @@ export function ScansPageShell({ hasManageScansPermission, activeScanCount = 0, children, + scanScheduleCapability, + isScanLimitReached, }: ScansPageShellProps) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -165,6 +171,8 @@ export function ScansPageShell({ open={launchOpen} onOpenChange={handleLaunchOpenChange} providers={providers} + capability={scanScheduleCapability} + isScanLimitReached={isScanLimitReached} />
); diff --git a/ui/components/scans/scans.utils.test.ts b/ui/components/scans/scans.utils.test.ts index 8568f86a86..1639f8f5a6 100644 --- a/ui/components/scans/scans.utils.test.ts +++ b/ui/components/scans/scans.utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + type MetaDataProps, + type ProviderProps, SCAN_JOBS_TAB, type ScanAttributes, type ScanProps, @@ -8,6 +10,8 @@ import { } from "@/types"; import { + appendPendingScheduleRowsToPage, + buildPendingScheduleRows, formatScanDuration, getScanAlias, getScanFindingsSummary, @@ -109,9 +113,9 @@ describe("scans.utils", () => { it("formats scan labels and durations for table display", () => { expect(getScanAlias(makeScan(""))).toBe("-"); expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe( - "scheduled scan", + "Scheduled Scan", ); - expect(getScanAlias(makeScan("", "scheduled"))).toBe("scheduled scan"); + expect(getScanAlias(makeScan("", "scheduled"))).toBe("Scheduled Scan"); expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan"); expect(formatScanDuration(73)).toBe("1 min 13 sec"); expect(formatScanDuration(null)).toBe("-"); @@ -161,3 +165,233 @@ describe("scans.utils", () => { expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull(); }); }); + +describe("buildPendingScheduleRows", () => { + const now = new Date("2026-06-10T10:30:00Z"); + + const makeProvider = (id: string): ProviderProps => ({ + id, + type: "providers", + attributes: { + provider: "aws", + uid: `uid-${id}`, + alias: `alias-${id}`, + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-06-10T10:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-06-10T10:00:00Z", + updated_at: "2026-06-10T10:00:00Z", + created_by: { + object: "users", + id: "user-1", + }, + }, + relationships: { + secret: { data: null }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + }); + + const makeScheduledScan = (id: string, providerId: string): ScanProps => ({ + ...makeScan("Scheduled Scan", "scheduled"), + id, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + task: { data: { type: "tasks", id: `task-${id}` } }, + }, + }); + + const makeMeta = ({ + page, + pages, + count, + }: { + page: number; + pages: number; + count: number; + }): MetaDataProps => ({ + pagination: { + page, + pages, + count, + itemsPerPage: [1, 2, 10], + }, + version: "v1", + }); + + const weeklySchedule = { + scan_enabled: true, + scan_frequency: "WEEKLY", + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: 1, + scan_day_of_month: null, + } as const; + + it("synthesizes a pending row for a configured schedule without scan rows", () => { + const rows = buildPendingScheduleRows({ + providers: [makeProvider("p1")], + schedulesByProviderId: { p1: weeklySchedule }, + coveredProviderIds: new Set(), + now, + }); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe("pending-schedule-p1"); + expect(rows[0].attributes.state).toBe("scheduled"); + expect(rows[0].attributes.trigger).toBe("scheduled"); + expect(rows[0].pendingSchedule?.summary).toBe( + "Weekly on Monday @ 9:00am (Europe/Madrid)", + ); + expect(rows[0].pendingSchedule?.cadence).toBe("Weekly on Monday"); + expect(rows[0].providerInfo?.uid).toBe("uid-p1"); + }); + + it("uses global covered providers when appending pending rows", () => { + // Given - p1 has a real scheduled scan on a previous page, p2 only has a configured schedule. + const result = appendPendingScheduleRowsToPage({ + scans: [], + meta: makeMeta({ page: 2, pages: 1, count: 1 }), + page: 2, + pageSize: 1, + providers: [makeProvider("p1"), makeProvider("p2")], + schedulesByProviderId: { + p1: weeklySchedule, + p2: weeklySchedule, + }, + coveredProviderIds: new Set(["p1"]), + now, + }); + + // Then - p1 is not duplicated as a synthetic pending row. + expect(result.data.map((scan) => scan.id)).toEqual(["pending-schedule-p2"]); + expect(result.meta?.pagination.count).toBe(2); + expect(result.meta?.pagination.pages).toBe(2); + }); + + it("keeps the last real page within page size and carries pending rows to the next page", () => { + // Given - three real scheduled scans and two pending schedules at page size 2. + const providers = ["p1", "p2", "p3", "p4", "p5"].map(makeProvider); + const schedulesByProviderId = Object.fromEntries( + providers.map((provider) => [provider.id, weeklySchedule]), + ); + + const lastRealPage = appendPendingScheduleRowsToPage({ + scans: [makeScheduledScan("scan-3", "p3")], + meta: makeMeta({ page: 2, pages: 2, count: 3 }), + page: 2, + pageSize: 2, + providers, + schedulesByProviderId, + coveredProviderIds: new Set(["p1", "p2", "p3"]), + now, + }); + + const firstPendingOnlyPage = appendPendingScheduleRowsToPage({ + scans: [], + meta: makeMeta({ page: 3, pages: 2, count: 3 }), + page: 3, + pageSize: 2, + providers, + schedulesByProviderId, + coveredProviderIds: new Set(["p1", "p2", "p3"]), + now, + }); + + // Then - page 2 gets one pending row, page 3 gets the remaining pending row. + expect(lastRealPage.data.map((scan) => scan.id)).toEqual([ + "scan-3", + "pending-schedule-p4", + ]); + expect(lastRealPage.meta?.pagination.count).toBe(5); + expect(lastRealPage.meta?.pagination.pages).toBe(3); + expect(firstPendingOnlyPage.data.map((scan) => scan.id)).toEqual([ + "pending-schedule-p5", + ]); + }); + + it("shows pending rows on an otherwise empty scheduled tab with coherent metadata", () => { + // Given + const result = appendPendingScheduleRowsToPage({ + scans: [], + meta: makeMeta({ page: 1, pages: 0, count: 0 }), + page: 1, + pageSize: 10, + providers: [makeProvider("p1")], + schedulesByProviderId: { p1: weeklySchedule }, + coveredProviderIds: new Set(), + now, + }); + + // Then + expect(result.data.map((scan) => scan.id)).toEqual(["pending-schedule-p1"]); + expect(result.meta?.pagination.count).toBe(1); + expect(result.meta?.pagination.pages).toBe(1); + expect(result.meta?.pagination.page).toBe(1); + }); + + it("prefers the server-computed next_scan_at and carries last_scan_at", () => { + const rows = buildPendingScheduleRows({ + providers: [makeProvider("p1")], + schedulesByProviderId: { + p1: { + ...weeklySchedule, + next_scan_at: "2026-06-15T00:00:00Z", + last_scan_at: "2026-06-01T10:00:00Z", + }, + }, + coveredProviderIds: new Set(), + now, + }); + + expect(rows[0].attributes.scheduled_at).toBe("2026-06-15T00:00:00Z"); + expect(rows[0].pendingSchedule?.nextScanAt).toBe("2026-06-15T00:00:00Z"); + expect(rows[0].pendingSchedule?.lastScanAt).toBe("2026-06-01T10:00:00Z"); + }); + + it("falls back to a client estimate when next_scan_at is absent", () => { + const rows = buildPendingScheduleRows({ + providers: [makeProvider("p1")], + schedulesByProviderId: { p1: weeklySchedule }, + coveredProviderIds: new Set(), + now, + }); + + expect(rows[0].attributes.scheduled_at).not.toBeNull(); + expect(rows[0].pendingSchedule?.lastScanAt).toBeNull(); + }); + + it("skips providers already covered by a real scheduled scan row", () => { + const rows = buildPendingScheduleRows({ + providers: [makeProvider("p1")], + schedulesByProviderId: { p1: weeklySchedule }, + coveredProviderIds: new Set(["p1"]), + now, + }); + + expect(rows).toHaveLength(0); + }); + + it("skips unconfigured and disabled schedules", () => { + const rows = buildPendingScheduleRows({ + providers: [makeProvider("p1"), makeProvider("p2"), makeProvider("p3")], + schedulesByProviderId: { + p1: { ...weeklySchedule, scan_hour: null }, + p2: { ...weeklySchedule, scan_enabled: false }, + }, + coveredProviderIds: new Set(), + now, + }); + + expect(rows).toHaveLength(0); + }); +}); diff --git a/ui/components/scans/scans.utils.ts b/ui/components/scans/scans.utils.ts index f7c8f546f4..4d96046019 100644 --- a/ui/components/scans/scans.utils.ts +++ b/ui/components/scans/scans.utils.ts @@ -1,5 +1,13 @@ +import { + describeScheduleCadence, + getNextScheduledRunInTimezone, + getScheduleCadenceParts, + isScheduleConfigured, +} from "@/lib/schedules"; import { DEFAULT_SCAN_JOBS_TAB, + type MetaDataProps, + type ProviderProps, SCAN_JOBS_TAB, SCAN_STATE, SCAN_TRIGGER, @@ -9,6 +17,7 @@ import { type ScanProps, type ScanState, type ScanTrigger, + type ScheduleAttributes, type SearchParamsProps, } from "@/types"; @@ -136,7 +145,7 @@ export function getScanJobsTabFilters( export function getScanAlias(scan: ScanProps): string { if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED) - return "scheduled scan"; + return "Scheduled Scan"; return scan.attributes.name?.trim() || "-"; } @@ -179,6 +188,142 @@ export function getScanStatusFilterOptions( ]; } +export interface BuildPendingScheduleRowsParams { + providers: ProviderProps[]; + schedulesByProviderId: Record; + /** Providers that already have a real `state=scheduled` Scan row. */ + coveredProviderIds: Set; + now: Date; +} + +interface AppendPendingScheduleRowsToPageParams + extends BuildPendingScheduleRowsParams { + scans: ScanProps[]; + meta?: MetaDataProps; + page: number; + pageSize: number; +} + +/** + * Synthesizes Scheduled-tab rows for configured schedules without a Scan row + * yet (the backend only creates one after each run). + */ +export function buildPendingScheduleRows({ + providers, + schedulesByProviderId, + coveredProviderIds, + now, +}: BuildPendingScheduleRowsParams): ScanProps[] { + return providers.flatMap((provider) => { + if (coveredProviderIds.has(provider.id)) return []; + + const schedule = schedulesByProviderId[provider.id]; + if (!schedule || !isScheduleConfigured(schedule) || !schedule.scan_enabled) + return []; + + // Prefer the server-computed next fire time; fall back to a client estimate. + const nextScanAt = + schedule.next_scan_at ?? + getNextScheduledRunInTimezone(schedule, now)?.toISOString() ?? + null; + + return [ + { + type: "scans" as const, + id: `pending-schedule-${provider.id}`, + attributes: { + name: "", + trigger: SCAN_TRIGGER.SCHEDULED, + state: SCAN_STATE.SCHEDULED, + unique_resource_count: 0, + progress: 0, + scanner_args: null, + duration: null, + started_at: null, + inserted_at: now.toISOString(), + completed_at: null, + scheduled_at: nextScanAt, + next_scan_at: null, + }, + relationships: { + provider: { data: { type: "providers", id: provider.id } }, + // No Celery task behind synthetic rows. + task: { data: { type: "tasks", id: "" } }, + }, + providerInfo: { + provider: provider.attributes.provider, + uid: provider.attributes.uid, + alias: provider.attributes.alias, + }, + pendingSchedule: { + summary: describeScheduleCadence(schedule), + cadence: getScheduleCadenceParts(schedule).cadence, + nextScanAt, + lastScanAt: schedule.last_scan_at ?? null, + }, + }, + ]; + }); +} + +export function getProviderIdsFromScans(scans: ScanProps[]): Set { + return new Set( + scans + .map((scan) => scan.relationships?.provider?.data?.id) + .filter((id): id is string => Boolean(id)), + ); +} + +export function appendPendingScheduleRowsToPage({ + scans, + meta, + page, + pageSize, + providers, + schedulesByProviderId, + coveredProviderIds, + now, +}: AppendPendingScheduleRowsToPageParams): { + data: ScanProps[]; + meta?: MetaDataProps; +} { + // The API paginates real scheduled scans, while pending rows come from + // configured schedules without a scan yet. Reconcile both sources here so the + // rendered rows and pagination metadata describe the same combined list. + const pendingRows = buildPendingScheduleRows({ + providers, + schedulesByProviderId, + coveredProviderIds, + now, + }); + const safePageSize = Math.max(1, pageSize); + const realCount = meta?.pagination.count ?? scans.length; + const pendingStart = Math.max(0, (page - 1) * safePageSize - realCount); + const pendingSlots = Math.max(0, safePageSize - scans.length); + const pagePendingRows = pendingRows.slice( + pendingStart, + pendingStart + pendingSlots, + ); + const combinedCount = realCount + pendingRows.length; + const combinedPages = + combinedCount === 0 ? 0 : Math.ceil(combinedCount / safePageSize); + + return { + data: [...scans, ...pagePendingRows], + meta: meta + ? { + ...meta, + pagination: { + ...meta.pagination, + page, + count: combinedCount, + pages: combinedPages, + }, + } + : undefined, + }; +} + function getNumericValue( source: Record, keys: string[], diff --git a/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx b/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx new file mode 100644 index 0000000000..9d1e96b46a --- /dev/null +++ b/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx @@ -0,0 +1,165 @@ +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(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock }), +})); + +vi.mock("@/actions/schedules", () => ({ + removeSchedule: removeScheduleMock, + updateSchedule: updateScheduleMock, +})); + +vi.mock("@/components/ui/toast", () => ({ + toast: toastMock, +})); + +vi.mock("@/components/ui/entities", () => ({ + EntityInfo: () => null, +})); + +vi.mock("@/components/shadcn/modal", () => ({ + Modal: ({ + children, + open, + title, + }: { + children: React.ReactNode; + open: boolean; + title: string; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +import type { ScheduleProps } from "@/types/schedules"; + +import { + EDIT_SCAN_SCHEDULE_STATE, + EditScanScheduleModal, +} from "./edit-scan-schedule-modal"; + +const provider = { + providerId: "p1", + providerType: "aws" as const, + providerUid: "123456789012", + providerAlias: "Production", +}; + +const schedule: ScheduleProps = { + type: "schedules", + id: "p1", + attributes: { + scan_enabled: true, + scan_frequency: "DAILY", + scan_hour: 4, + scan_timezone: "UTC", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + }, + relationships: { + provider: { data: { type: "providers", id: "p1" } }, + }, +}; + +const renderLoaded = () => + render( + , + ); + +describe("EditScanScheduleModal remove flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + removeScheduleMock.mockResolvedValue({ success: true }); + }); + + it("asks for confirmation before removing the schedule", async () => { + const user = userEvent.setup(); + renderLoaded(); + + await user.click( + screen.getByRole("button", { name: /remove scan schedule/i }), + ); + + expect(removeScheduleMock).not.toHaveBeenCalled(); + expect( + screen.getByRole("dialog", { name: "Are you absolutely sure?" }), + ).toBeInTheDocument(); + }); + + it("removes the schedule only after confirming", async () => { + const user = userEvent.setup(); + renderLoaded(); + + await user.click( + screen.getByRole("button", { name: /remove scan schedule/i }), + ); + const confirmDialog = screen.getByRole("dialog", { + name: "Are you absolutely sure?", + }); + await user.click( + within(confirmDialog).getByRole("button", { name: "Remove" }), + ); + + await waitFor(() => expect(removeScheduleMock).toHaveBeenCalledWith("p1")); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ title: "Scan schedule removed" }), + ); + }); + + it("hides the remove button when the provider has no configured schedule", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /remove scan schedule/i }), + ).not.toBeInTheDocument(); + }); + + it("keeps the schedule when the confirmation is cancelled", async () => { + const user = userEvent.setup(); + renderLoaded(); + + await user.click( + screen.getByRole("button", { name: /remove scan schedule/i }), + ); + const confirmDialog = screen.getByRole("dialog", { + name: "Are you absolutely sure?", + }); + await user.click( + within(confirmDialog).getByRole("button", { name: "Cancel" }), + ); + + expect(removeScheduleMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/scans/schedule/edit-scan-schedule-modal.tsx b/ui/components/scans/schedule/edit-scan-schedule-modal.tsx new file mode 100644 index 0000000000..c84d79e7f3 --- /dev/null +++ b/ui/components/scans/schedule/edit-scan-schedule-modal.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { CircleX, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { removeSchedule, updateSchedule } from "@/actions/schedules"; +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 { + buildScheduleUpdatePayload, + getScheduleFormValues, + isScheduleConfigured, + scheduleFormSchema, +} from "@/lib/schedules"; +import type { ProviderType, ScheduleProps } from "@/types"; +import type { 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", + ERROR: "error", +} as const; + +export type EditScanScheduleState = + | { kind: typeof EDIT_SCAN_SCHEDULE_STATE.LOADING } + | { + kind: typeof EDIT_SCAN_SCHEDULE_STATE.LOADED; + schedule: ScheduleProps | null; + } + | { kind: typeof EDIT_SCAN_SCHEDULE_STATE.ERROR; message: string }; + +interface EditScanScheduleFormProps { + provider: ScanScheduleProvider; + schedule: ScheduleProps | null; + onClose: () => void; +} + +interface EditScanScheduleModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + provider?: ScanScheduleProvider; + state: EditScanScheduleState; +} + +function EditScanScheduleForm({ + provider, + schedule, + onClose, +}: EditScanScheduleFormProps) { + const router = useRouter(); + const [isConfirmRemoveOpen, setIsConfirmRemoveOpen] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormValues(schedule?.attributes), + }); + const hasSchedule = schedule + ? isScheduleConfigured(schedule.attributes) + : false; + + const onSubmit = form.handleSubmit(async (values) => { + const result = await updateSchedule( + provider.providerId, + buildScheduleUpdatePayload(values), + ); + + if (result?.error) { + form.setError("root", { message: String(result.error) }); + return; + } + + toast({ + title: "Scan schedule saved", + description: "The scan schedule was updated successfully.", + }); + onClose(); + router.refresh(); + }); + + const handleRemove = async () => { + setIsRemoving(true); + const result = await removeSchedule(provider.providerId); + setIsRemoving(false); + setIsConfirmRemoveOpen(false); + + if (result?.error) { + form.setError("root", { message: String(result.error) }); + return; + } + + toast({ + title: "Scan schedule removed", + description: "The scan schedule was removed successfully.", + }); + onClose(); + router.refresh(); + }; + + const isSubmitting = form.formState.isSubmitting; + const rootError = form.formState.errors.root?.message; + + return ( +
+ + + setIsConfirmRemoveOpen(true)} + disabled={isSubmitting || isRemoving} + className="text-text-error-primary" + > + + Remove Scan Schedule + + ) : undefined + } + /> + + {rootError && {rootError}} + + + + +
+ + +
+
+ + ); +} + +export function EditScanScheduleModal({ + open, + onOpenChange, + provider, + state, +}: EditScanScheduleModalProps) { + const close = () => onOpenChange(false); + + return ( + + {state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADING && ( +
+ + Loading scan schedule... +
+ )} + + {state.kind === EDIT_SCAN_SCHEDULE_STATE.ERROR && ( +
+ {state.message} + +
+ )} + + {state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && provider && ( + + )} +
+ ); +} diff --git a/ui/components/scans/schedule/save-schedule.test.ts b/ui/components/scans/schedule/save-schedule.test.ts new file mode 100644 index 0000000000..cf56f1fb18 --- /dev/null +++ b/ui/components/scans/schedule/save-schedule.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SCHEDULE_FREQUENCY, type ScheduleFormValues } from "@/types/schedules"; + +const { scanOnDemandMock, scheduleDailyMock, updateScheduleMock } = vi.hoisted( + () => ({ + scanOnDemandMock: vi.fn(), + scheduleDailyMock: vi.fn(), + updateScheduleMock: vi.fn(), + }), +); + +vi.mock("@/actions/scans", () => ({ + scanOnDemand: scanOnDemandMock, + scheduleDaily: scheduleDailyMock, +})); + +vi.mock("@/actions/schedules", () => ({ + updateSchedule: updateScheduleMock, +})); + +import { + SAVE_SCHEDULE_STATUS, + saveScheduleWithInitialScan, +} from "./save-schedule"; + +const values: ScheduleFormValues = { + frequency: SCHEDULE_FREQUENCY.WEEKLY, + hour: 9, + dayOfWeek: 1, + dayOfMonth: 1, + intervalHours: 48, + launchInitialScan: false, +}; + +describe("saveScheduleWithInitialScan", () => { + beforeEach(() => { + vi.clearAllMocks(); + updateScheduleMock.mockResolvedValue({}); + scheduleDailyMock.mockResolvedValue({}); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("saves through the schedules API with the mapped payload", async () => { + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values, + }); + + expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED); + expect(updateScheduleMock).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 9, + scan_day_of_week: 1, + }), + ); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + }); + + it("uses the legacy daily endpoint when requested", async () => { + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values, + useLegacyDaily: true, + }); + + expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED); + expect(scheduleDailyMock).toHaveBeenCalledTimes(1); + expect(updateScheduleMock).not.toHaveBeenCalled(); + }); + + it("returns an error status when the schedule save fails", async () => { + updateScheduleMock.mockResolvedValue({ error: "boom" }); + + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values, + }); + + expect(result).toEqual({ + status: SAVE_SCHEDULE_STATUS.ERROR, + message: "boom", + }); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + }); + + it("launches the initial scan when requested", async () => { + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values: { ...values, launchInitialScan: true }, + }); + + expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED); + const formData = scanOnDemandMock.mock.calls[0][0] as FormData; + expect(formData.get("providerId")).toBe("p1"); + }); + + it("reports partial success when the initial scan fails", async () => { + scanOnDemandMock.mockResolvedValue({ error: "limit reached" }); + + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values: { ...values, launchInitialScan: true }, + }); + + expect(result).toEqual({ + status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED, + message: "limit reached", + }); + }); +}); diff --git a/ui/components/scans/schedule/save-schedule.ts b/ui/components/scans/schedule/save-schedule.ts new file mode 100644 index 0000000000..7e3cfabb45 --- /dev/null +++ b/ui/components/scans/schedule/save-schedule.ts @@ -0,0 +1,70 @@ +import { scanOnDemand, scheduleDaily } from "@/actions/scans"; +import { updateSchedule } from "@/actions/schedules"; +import { buildScheduleUpdatePayload } from "@/lib/schedules"; +import type { ScheduleFormValues } from "@/types/schedules"; + +export const SAVE_SCHEDULE_STATUS = { + ERROR: "error", + SAVED: "saved", + SAVED_AND_LAUNCHED: "saved_and_launched", + SAVED_SCAN_FAILED: "saved_scan_failed", +} as const; + +export type SaveScheduleStatus = + (typeof SAVE_SCHEDULE_STATUS)[keyof typeof SAVE_SCHEDULE_STATUS]; + +export interface SaveScheduleParams { + providerId: string; + values: ScheduleFormValues; + /** Save through the legacy `/schedules/daily` endpoint (OSS / non-Cloud). */ + useLegacyDaily?: boolean; +} + +export interface SaveScheduleResult { + status: SaveScheduleStatus; + message?: string; +} + +/** Saves a provider's scan schedule and optionally launches the initial scan. */ +export async function saveScheduleWithInitialScan({ + providerId, + values, + useLegacyDaily = false, +}: SaveScheduleParams): Promise { + let scheduleResult: { error?: unknown } | null; + + if (useLegacyDaily) { + const formData = new FormData(); + formData.set("providerId", providerId); + scheduleResult = await scheduleDaily(formData); + } else { + scheduleResult = await updateSchedule( + providerId, + buildScheduleUpdatePayload(values), + ); + } + + if (scheduleResult?.error) { + return { + status: SAVE_SCHEDULE_STATUS.ERROR, + message: String(scheduleResult.error), + }; + } + + if (!values.launchInitialScan) { + return { status: SAVE_SCHEDULE_STATUS.SAVED }; + } + + const formData = new FormData(); + formData.set("providerId", providerId); + const scanResult = await scanOnDemand(formData); + + if (scanResult?.error) { + return { + status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED, + message: String(scanResult.error), + }; + } + + return { status: SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED }; +} diff --git a/ui/components/scans/schedule/scan-schedule-fields.test.tsx b/ui/components/scans/schedule/scan-schedule-fields.test.tsx new file mode 100644 index 0000000000..ed8774db5f --- /dev/null +++ b/ui/components/scans/schedule/scan-schedule-fields.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getScheduleFormDefaults } from "@/lib/schedules"; +import type { ScheduleFormValues } from "@/types/schedules"; + +import { ScanScheduleFields } from "./scan-schedule-fields"; + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); + +function ScheduleFieldsHarness() { + const form = useForm({ + defaultValues: getScheduleFormDefaults(), + }); + + return ( + + ); +} + +function getHelperCopy(text: RegExp) { + return screen.getByText((_, element) => { + return ( + element?.tagName.toLowerCase() === "p" && + text.test(element.textContent ?? "") + ); + }); +} + +describe("ScanScheduleFields", () => { + it("updates the helper copy when the cadence changes to interval", async () => { + // Given + const user = userEvent.setup(); + render(); + + expect(getHelperCopy(/Daily/)).toBeInTheDocument(); + + // When + await user.click(screen.getByRole("combobox", { name: /repeats/i })); + await user.click(screen.getByRole("option", { name: /every 48 hours/i })); + + // Then + expect(getHelperCopy(/Every 48 hours/)).toBeInTheDocument(); + expect( + screen.queryByText((_, element) => { + return ( + element?.tagName.toLowerCase() === "p" && + /Daily/.test(element.textContent ?? "") + ); + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/schedule/scan-schedule-fields.tsx b/ui/components/scans/schedule/scan-schedule-fields.tsx new file mode 100644 index 0000000000..d01883b23c --- /dev/null +++ b/ui/components/scans/schedule/scan-schedule-fields.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { format } from "date-fns"; +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { Controller, type UseFormReturn, useWatch } from "react-hook-form"; + +import { + Checkbox, + Field, + FieldLabel, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn"; +import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { + formatScheduleHour, + getBrowserTimezone, + getNextScheduledRun, +} from "@/lib/schedules"; +import { + SCHEDULE_FREQUENCY, + SCHEDULE_WEEKDAY_LABELS, + type ScheduleFormValues, +} from "@/types/schedules"; + +// The INTERVAL label is resolved at render time from the form's intervalHours. +const FREQUENCY_OPTIONS = [ + { value: SCHEDULE_FREQUENCY.DAILY, label: "Daily" }, + { value: SCHEDULE_FREQUENCY.INTERVAL, label: null }, + { value: SCHEDULE_FREQUENCY.WEEKLY, label: "Weekly" }, + { value: SCHEDULE_FREQUENCY.MONTHLY, label: "Monthly" }, +] as const; + +const WEEKDAY_OPTIONS = SCHEDULE_WEEKDAY_LABELS.map((label, value) => ({ + value, + label, +})); + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({ + value: hour, + label: formatScheduleHour(hour), +})); + +const MONTH_DAY_OPTIONS = Array.from({ length: 28 }, (_, index) => index + 1); + +interface ScanScheduleFieldsProps { + form: UseFormReturn; + disabled?: boolean; + showLaunchInitialScan?: boolean; + showNextScheduledCopy?: boolean; + /** Rendered at the right of the "Scan Schedule" header row. */ + headerAction?: ReactNode; + /** + * When false, the frequency is locked to `Daily` and the advanced cadences + * (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts. + */ + canUseAdvancedSchedule?: boolean; + /** Render the "Available in Prowler Cloud" upsell badge on locked controls. */ + showCloudUpgradeBadge?: boolean; +} + +function NumberSelect({ + label, + labelAddon, + value, + values, + onChange, + disabled, +}: { + label: string; + labelAddon?: ReactNode; + value: number; + values: ReadonlyArray<{ value: number; label: string }>; + onChange: (value: number) => void; + disabled?: boolean; +}) { + return ( + +
+ {label} + {labelAddon} +
+ +
+ ); +} + +function getScheduleSummary({ + frequency, + intervalHours, + dayOfWeek, + dayOfMonth, +}: Pick< + ScheduleFormValues, + "frequency" | "intervalHours" | "dayOfWeek" | "dayOfMonth" +>) { + switch (frequency) { + case SCHEDULE_FREQUENCY.INTERVAL: + return `Every ${intervalHours} hours`; + case SCHEDULE_FREQUENCY.WEEKLY: + return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`; + case SCHEDULE_FREQUENCY.MONTHLY: + return `Monthly on day ${dayOfMonth}`; + default: + return "Daily"; + } +} + +export function ScanScheduleFields({ + form, + disabled = false, + showLaunchInitialScan = false, + showNextScheduledCopy = false, + headerAction, + canUseAdvancedSchedule = true, + showCloudUpgradeBadge = false, +}: ScanScheduleFieldsProps) { + // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. + const control = form.control; + const [frequency, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({ + control, + name: ["frequency", "hour", "dayOfWeek", "dayOfMonth", "intervalHours"], + }); + const timezone = getBrowserTimezone(); + const scheduleSummary = getScheduleSummary({ + frequency, + intervalHours, + dayOfWeek, + dayOfMonth, + }); + const frequencyLabel = (option: (typeof FREQUENCY_OPTIONS)[number]) => + option.label ?? `Every ${intervalHours} hours`; + // In OSS (non-Cloud) the advanced cadence and time are locked: `/schedules/daily` + // ignores them, so they are display-only with a Cloud upsell. + const advancedDisabled = disabled || !canUseAdvancedSchedule; + const cloudUpgradeBadge = showCloudUpgradeBadge ? ( + + ) : null; + + return ( +
+
+
+ +

+ Scan Schedule +

+
+ {headerAction} +
+ +
+ ( + + )} + /> + + ( + +
+ Repeats + {cloudUpgradeBadge} +
+ +
+ )} + /> +
+ + {frequency === SCHEDULE_FREQUENCY.WEEKLY && ( + ( + + )} + /> + )} + + {frequency === SCHEDULE_FREQUENCY.MONTHLY && ( + ( + ({ + value: day, + label: String(day), + }))} + onChange={field.onChange} + disabled={disabled} + /> + )} + /> + )} + + {showNextScheduledCopy && + (canUseAdvancedSchedule ? ( +

+ {scheduleSummary}. The next scheduled scan will start on:{" "} + {format( + getNextScheduledRun( + { + frequency, + hour, + dayOfWeek, + dayOfMonth, + intervalHours, + launchInitialScan: false, + }, + new Date(), + ), + "MMM d, yyyy", + )}{" "} + @ {formatScheduleHour(hour)} {timezone} +

+ ) : ( +

+ A daily scan will run automatically once the account is connected. +

+ ))} + + {showLaunchInitialScan && ( + ( + + )} + /> + )} +
+ ); +} diff --git a/ui/components/scans/table/cells/scan-info-cell.tsx b/ui/components/scans/table/cells/scan-info-cell.tsx index aa61a118d0..7731a2fae1 100644 --- a/ui/components/scans/table/cells/scan-info-cell.tsx +++ b/ui/components/scans/table/cells/scan-info-cell.tsx @@ -1,10 +1,31 @@ "use client"; import { getScanAlias } from "@/components/scans/scans.utils"; +import { + Badge, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; import { EntityInfo } from "@/components/ui/entities"; import type { ScanProps } from "@/types"; export function ScanInfoCell({ scan }: { scan: ScanProps }) { + // Synthetic pending rows have no Scan behind them yet, so there is no id. + if (scan.pendingSchedule) { + return ( + + + Pending + + + This scan has not been created yet. Its details will appear after the + first scheduled run. + + + ); + } + return (
- - {getScanScheduleLabel(scan.attributes.trigger)} - - {scan.attributes.scheduled_at && ( - - )} -
+ ); } diff --git a/ui/components/scans/table/scan-jobs-columns.test.tsx b/ui/components/scans/table/scan-jobs-columns.test.tsx index 03f22cecc0..a8bde44a1c 100644 --- a/ui/components/scans/table/scan-jobs-columns.test.tsx +++ b/ui/components/scans/table/scan-jobs-columns.test.tsx @@ -1,14 +1,25 @@ import type { CellContext, HeaderContext } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ScanProps } from "@/types"; vi.mock("@/components/shadcn", () => ({ - Badge: ({ children }: { children: React.ReactNode }) => ( - {children} - ), + Badge: ({ children }: { children: ReactNode }) => {children}, Progress: () =>
, + StackedCell: ({ + primary, + secondary, + }: { + primary: ReactNode; + secondary?: ReactNode; + }) => ( +
+ {primary} + {secondary ? {secondary} : null} +
+ ), })); vi.mock("@/components/ui/entities", () => ({ @@ -148,6 +159,8 @@ describe("getScanJobsColumns", () => { "account", "scanInfo", "scanSchedule", + "nextScan", + "lastScan", "actions", ]); }); diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx index 824f1e6f91..d87553e50c 100644 --- a/ui/components/scans/table/scan-jobs-columns.tsx +++ b/ui/components/scans/table/scan-jobs-columns.tsx @@ -2,9 +2,10 @@ import type { ColumnDef } from "@tanstack/react-table"; -import { DateWithTime } from "@/components/ui/entities"; +import { StackedCell } from "@/components/shadcn"; 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 { formatScanDuration } from "../scans.utils"; @@ -48,15 +49,59 @@ const getScanScheduleColumn = (title: string): ColumnDef => ({ cell: ({ row }) => , }); +const getScheduleSummary = (scan: ScanProps) => + scan.pendingSchedule ?? scan.providerSchedule; + +const renderDateCell = (value: string | null) => { + const date = formatLocalDate(value); + if (!date) return -; + + return ( + + ); +}; + const scheduledScanScheduleColumn: ColumnDef = { id: "scanSchedule", accessorFn: (row) => row.attributes.scheduled_at, header: ({ column }) => ( ), - cell: ({ row }) => ( - + // Cadence on top, local fire time underneath. + cell: ({ row }) => { + const schedule = getScheduleSummary(row.original); + if (!schedule) return -; + + return ( + + ); + }, + enableSorting: false, +}; + +const nextScanColumn: ColumnDef = { + id: "nextScan", + header: ({ column }) => ( + ), + // Real rows carry their fire time in scheduled_at; pending rows are + // synthesized with the server-computed next_scan_at in the same field. + cell: ({ row }) => renderDateCell(row.original.attributes.scheduled_at), + enableSorting: false, +}; + +const lastScanColumn: ColumnDef = { + id: "lastScan", + header: ({ column }) => ( + + ), + cell: ({ row }) => + renderDateCell(getScheduleSummary(row.original)?.lastScanAt ?? null), enableSorting: false, }; @@ -104,14 +149,11 @@ const activeColumns = (): ColumnDef[] => [ header: ({ column }) => ( ), - cell: ({ row }) => ( - - ), + cell: ({ row }) => + renderDateCell( + row.original.attributes.started_at || + row.original.attributes.inserted_at, + ), enableSorting: false, }, actionsColumn, @@ -141,9 +183,7 @@ const completedColumns = (): ColumnDef[] => [ param="updated_at" /> ), - cell: ({ row }) => ( - - ), + cell: ({ row }) => renderDateCell(row.original.attributes.completed_at), }, actionsColumn, ]; @@ -152,19 +192,8 @@ const scheduledColumns = (): ColumnDef[] => [ accountColumn, scanInfoColumn, scheduledScanScheduleColumn, - /* - * TODO: Restore this column when the API exposes the last completed scan date for this schedule. - * { - * id: "lastScan", - * header: ({ column }) => ( - * - * ), - * cell: ({ row }) => ( - * - * ), - * enableSorting: false, - * }, - */ + nextScanColumn, + lastScanColumn, actionsColumn, ]; diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index 9475dcd8ce..7fcc97ecd6 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -1,19 +1,24 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ScanProps } from "@/types"; import { ScanJobsRowActions } from "./scan-jobs-row-actions"; -const { downloadScanZipMock, getTaskMock, pushMock, toastMock } = vi.hoisted( - () => ({ - downloadScanZipMock: vi.fn(), - getTaskMock: vi.fn(), - pushMock: vi.fn(), - toastMock: vi.fn(), - }), -); +const { + downloadScanZipMock, + getScheduleMock, + getTaskMock, + pushMock, + toastMock, +} = vi.hoisted(() => ({ + downloadScanZipMock: vi.fn(), + getScheduleMock: vi.fn(), + getTaskMock: vi.fn(), + pushMock: vi.fn(), + toastMock: vi.fn(), +})); vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -29,6 +34,10 @@ vi.mock("@/actions/task", () => ({ getTask: getTaskMock, })); +vi.mock("@/actions/schedules", () => ({ + getSchedule: getScheduleMock, +})); + vi.mock("@/lib/helper", () => ({ downloadScanZip: downloadScanZipMock, })); @@ -53,6 +62,26 @@ vi.mock("@/components/scans/edit-alias-modal", () => ({ ) : null, })); +vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({ + EDIT_SCAN_SCHEDULE_STATE: { + LOADING: "loading", + LOADED: "loaded", + ERROR: "error", + }, + EditScanScheduleModal: ({ + open, + provider, + }: { + open: boolean; + provider?: { providerId: string }; + }) => + open ? ( +
+ Editing schedule for {provider?.providerId} +
+ ) : null, +})); + const makeScan = ( overrides: Partial = {}, ): ScanProps => ({ @@ -80,6 +109,19 @@ const makeScan = ( }); describe("ScanJobsRowActions", () => { + beforeEach(() => { + getScheduleMock.mockResolvedValue({ + data: { + type: "schedules", + id: "provider-1", + attributes: { scan_hour: null }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + }); + }); + afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); @@ -95,7 +137,9 @@ describe("ScanJobsRowActions", () => { await user.click( screen.getByRole("button", { name: /open actions menu/i }), ); - await user.click(screen.getByRole("menuitem", { name: /^edit$/i })); + await user.click( + screen.getByRole("menuitem", { name: /edit scan alias/i }), + ); // Then expect( @@ -103,8 +147,31 @@ describe("ScanJobsRowActions", () => { ).toHaveTextContent("Editing Production scan"); }); - it("does not render the legacy Edit Scan Schedule option", async () => { + it("opens Edit Scan Schedule for Prowler Cloud subscribed scan rows", async () => { // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render(); + + // When + await user.click( + screen.getByRole("button", { name: /open actions menu/i }), + ); + + await user.click( + screen.getByRole("menuitem", { name: /edit scan schedule/i }), + ); + + // Then + expect( + screen.getByRole("dialog", { name: /edit scan schedule/i }), + ).toHaveTextContent("Editing schedule for provider-1"); + }); + + it("hides Edit Scan Schedule outside Prowler Cloud (OSS)", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); const user = userEvent.setup(); render(); @@ -120,24 +187,6 @@ describe("ScanJobsRowActions", () => { ).not.toBeInTheDocument(); }); - it("does not render cancel scan while the scan cancellation API is missing", async () => { - // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); - const user = userEvent.setup(); - - render(); - - // When - await user.click( - screen.getByRole("button", { name: /open actions menu/i }), - ); - - // Then - expect( - screen.queryByRole("menuitem", { name: /cancel scan/i }), - ).not.toBeInTheDocument(); - }); - it("links completed scans to compliance from the actions menu", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx index f5847ade6b..ff9991a842 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.tsx @@ -1,6 +1,7 @@ "use client"; import { + CalendarClock, Download, Eye, Pencil, @@ -10,6 +11,7 @@ import { import { useRouter } from "next/navigation"; import { useState } from "react"; +import { getSchedule } from "@/actions/schedules"; import { getTask } from "@/actions/task"; import { getScanErrorDetails } from "@/actions/task/task.adapter"; import { EditAliasModal } from "@/components/scans/edit-alias-modal"; @@ -17,6 +19,12 @@ import { ScanErrorDetailsModal, type ScanErrorDetailsState, } from "@/components/scans/scan-error-details-modal"; +import { + EDIT_SCAN_SCHEDULE_STATE, + EditScanScheduleModal, + type EditScanScheduleState, + type ScanScheduleProvider, +} from "@/components/scans/schedule/edit-scan-schedule-modal"; import { ActionDropdown, ActionDropdownItem, @@ -24,16 +32,36 @@ import { import { useToast } from "@/components/ui"; import { toLocalDateString } from "@/lib/date-utils"; import { downloadScanZip } from "@/lib/helper"; -import type { ScanProps } from "@/types"; +import { getScanScheduleCapability } from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; +import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, +} from "@/types/schedules"; interface ScanJobsRowActionsProps { scan: ScanProps; + /** + * Schedule capability override. Only for Prowler Cloud. + */ + capability?: ScanScheduleCapability; } -export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) { +export function ScanJobsRowActions({ + scan, + capability, +}: ScanJobsRowActionsProps) { const router = useRouter(); + const canEditSchedule = + (capability ?? getScanScheduleCapability(isCloud())) === + SCAN_SCHEDULE_CAPABILITY.ADVANCED; const { toast } = useToast(); const [editOpen, setEditOpen] = useState(false); + const [scheduleOpen, setScheduleOpen] = useState(false); + const [scheduleState, setScheduleState] = useState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADING, + }); const [errorOpen, setErrorOpen] = useState(false); const [errorState, setErrorState] = useState({ kind: "idle", @@ -43,6 +71,15 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) { const isFailed = scanState === "failed"; const taskId = scan.relationships.task.data?.id; const scanDate = toLocalDateString(scan.attributes.completed_at); + const providerId = scan.relationships.provider.data?.id; + const scheduleProvider: ScanScheduleProvider | undefined = providerId + ? { + providerId, + providerType: (scan.providerInfo?.provider ?? "aws") as ProviderType, + providerUid: scan.providerInfo?.uid ?? providerId, + providerAlias: scan.providerInfo?.alias ?? null, + } + : undefined; const openFindings = () => { if (!isCompleted || !scanDate) return; @@ -96,9 +133,58 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) { setErrorState({ kind: "loaded", details }); }; + const openScheduleEditor = async () => { + if (!providerId) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: "Provider ID is not available for this scan.", + }); + setScheduleOpen(true); + return; + } + + setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING }); + setScheduleOpen(true); + + const response = (await getSchedule(providerId)) as + | ScheduleApiResponse + | { error?: string }; + + if (!response || ("error" in response && response.error)) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: + response && "error" in response && response.error + ? response.error + : "Failed to load scan schedule.", + }); + return; + } + + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, + schedule: "data" in response ? response.data : null, + }); + }; + return (
+ {canEditSchedule && ( + } + label="Edit Scan Schedule" + onSelect={() => void openScheduleEditor()} + /> + )} + {/* Synthetic pending rows have no Scan to alias. */} + {!scan.pendingSchedule && ( + } + label="Edit Scan Alias" + onSelect={() => setEditOpen(true)} + /> + )} {isCompleted && ( <> void openErrorDetails()} /> )} - {/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */} - } - label="Edit" - onSelect={() => setEditOpen(true)} - /> {/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */} @@ -147,6 +227,13 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) { onOpenChange={setErrorOpen} state={errorState} /> + +
); } diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index 91c08ef72e..18ac317ef5 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -20,6 +20,7 @@ export * from "./select/multiselect"; export * from "./select/select"; export * from "./separator/separator"; export * from "./skeleton/skeleton"; +export * from "./stacked-cell/stacked-cell"; export * from "./tabs/generic-tabs"; export * from "./tabs/tabs"; export * from "./textarea/textarea"; diff --git a/ui/components/shadcn/select/multiselect.test.tsx b/ui/components/shadcn/select/multiselect.test.tsx index 94d03b3bd5..3959d16053 100644 --- a/ui/components/shadcn/select/multiselect.test.tsx +++ b/ui/components/shadcn/select/multiselect.test.tsx @@ -214,7 +214,7 @@ describe("MultiSelect", () => { ).not.toBeInTheDocument(); }); - it("uses a normalized dropdown width instead of growing with the longest item", async () => { + it("sizes the dropdown to its content with a capped width", async () => { const user = userEvent.setup(); render( @@ -233,10 +233,8 @@ describe("MultiSelect", () => { await user.click(screen.getByRole("combobox")); - expect(screen.getByRole("dialog")).toHaveClass( - "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))]", - ); - expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]"); + expect(screen.getByRole("dialog")).toHaveClass("sm:w-max"); + expect(screen.getByRole("dialog")).toHaveClass("sm:max-w-[22rem]"); }); it("keeps long option lists scrollable inside the dropdown", async () => { diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index f89c1394ba..209edbe599 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -313,8 +313,8 @@ export function MultiSelectContent({ const widthClasses = width === "wide" - ? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]" - : "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]"; + ? "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(max(var(--radix-popover-trigger-width),24rem),32rem)] sm:max-w-[32rem]" + : "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(var(--radix-popover-trigger-width),22rem)] sm:max-w-[22rem]"; function handleSearchValueChange(searchValue: string) { if (!canSearch || !searchValue.trim()) return; @@ -426,7 +426,7 @@ export function MultiSelectItem({ onSelect?.(value); }} > - + {children} { + it("renders primary and secondary lines", () => { + render(); + + expect(screen.getByText("Jun 15, 2026")).toBeInTheDocument(); + expect(screen.getByText("12:00AM MAD")).toBeInTheDocument(); + }); + + it("omits the secondary line when not provided", () => { + const { container } = render(); + + expect(screen.getByText("Manual")).toBeInTheDocument(); + expect(container.querySelectorAll("span")).toHaveLength(1); + }); +}); diff --git a/ui/components/shadcn/stacked-cell/stacked-cell.tsx b/ui/components/shadcn/stacked-cell/stacked-cell.tsx new file mode 100644 index 0000000000..3c3184ed3e --- /dev/null +++ b/ui/components/shadcn/stacked-cell/stacked-cell.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +interface StackedCellProps { + primary: ReactNode; + secondary?: ReactNode; + className?: string; +} + +/** + * Presentational shell for two-line table cells (the DateWithTime look): + * primary line on top, muted secondary line underneath. Content/formatting + * belongs to the caller. + */ +export function StackedCell({ + primary, + secondary, + className, +}: StackedCellProps) { + return ( +
+ + {primary} + + {secondary ? ( + + {secondary} + + ) : null} +
+ ); +} diff --git a/ui/components/ui/entities/date-with-time.tsx b/ui/components/ui/entities/date-with-time.tsx index 16cab1d7ff..8a4eedc49b 100644 --- a/ui/components/ui/entities/date-with-time.tsx +++ b/ui/components/ui/entities/date-with-time.tsx @@ -5,6 +5,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { formatLocalTimeWithZone } from "@/lib/date-utils"; import { cn } from "@/lib/utils"; interface DateWithTimeProps { @@ -29,18 +30,12 @@ export const DateWithTime = ({ } const formattedDate = format(date, "MMM dd, yyyy"); - const formattedTime = format(date, "h:mma"); - const timezone = - Intl.DateTimeFormat() - .resolvedOptions() - .timeZone.split("/") - .pop() - ?.substring(0, 3) - .toUpperCase() || ""; + const timeWithZone = formatLocalTimeWithZone(dateTime); - const fullText = showTime - ? `${formattedDate} ${formattedTime} ${timezone}` - : formattedDate; + const fullText = + showTime && timeWithZone + ? `${formattedDate} ${timeWithZone}` + : formattedDate; const content = (
{formattedDate} - {showTime && ( + {showTime && timeWithZone && ( - {formattedTime} {timezone} + {timeWithZone} )}
diff --git a/ui/components/ui/table/data-table-expand-toggle.tsx b/ui/components/ui/table/data-table-expand-toggle.tsx index 5bd7f13c09..7e18b4e510 100644 --- a/ui/components/ui/table/data-table-expand-toggle.tsx +++ b/ui/components/ui/table/data-table-expand-toggle.tsx @@ -44,14 +44,14 @@ export function DataTableExpandToggle({ const isExpanded = isExpandedProp ?? row.getIsExpanded(); if (!row.getCanExpand()) { - return
; + return
; } return ( , + enableSorting: false, + }, +]; + const metadata: MetaDataProps = { pagination: { page: 1, @@ -99,4 +112,60 @@ describe("DataTable", () => { expect(rightContent).toHaveClass("md:w-auto"); }); }); + + describe("when an actions column is present", () => { + it("should keep the actions header and cells sticky on the right edge", () => { + // Given + render( + , + ); + + // When + const columnHeaders = screen.getAllByRole("columnheader"); + const nameHeader = columnHeaders[0]; + const actionsHeader = columnHeaders.at(-1); + expect(actionsHeader).toBeDefined(); + const actionsHeaderElement = actionsHeader as HTMLElement; + const nameCell = screen.getByText("Finding A").closest("td"); + const actionsCell = screen + .getByRole("button", { + name: "Actions", + }) + .closest("td"); + + // Then + expect(nameHeader).not.toHaveClass("sticky"); + expect(nameCell).not.toHaveClass("sticky"); + expect(actionsHeaderElement).not.toHaveClass("sticky"); + expect(actionsHeaderElement).not.toHaveClass("right-0"); + expect(actionsHeaderElement).not.toHaveClass("z-20"); + expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary"); + expect(actionsHeaderElement).toHaveClass("border-y"); + expect(actionsHeaderElement).toHaveClass("last:border-r"); + expect(actionsHeaderElement).toHaveClass("last:rounded-r-full"); + expect(actionsHeaderElement).not.toHaveClass("bg-transparent"); + expect(actionsHeaderElement).not.toHaveClass("border-y-0"); + expect(actionsHeaderElement).not.toHaveClass("border-l-0"); + expect(actionsHeaderElement).not.toHaveClass("border-r-0"); + expect(actionsHeaderElement).not.toHaveClass("last:border-r-0"); + expect(actionsHeaderElement).not.toHaveClass("backdrop-blur-none"); + expect(actionsHeaderElement).not.toHaveClass("last:rounded-r-none"); + expect(actionsHeaderElement).not.toHaveClass("before:bg-gradient-to-r"); + expect(actionsHeaderElement).not.toHaveClass("before:content-['']"); + expect(actionsHeaderElement).not.toHaveClass("after:content-['']"); + expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full"); + expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument(); + expect(actionsCell).toHaveClass("sticky"); + expect(actionsCell).toHaveClass("right-0"); + expect(actionsCell).toHaveClass("z-20"); + expect(actionsCell).toHaveClass("bg-bg-neutral-secondary"); + expect(actionsCell).not.toHaveClass("border-l"); + expect(actionsCell).toHaveClass("before:bg-gradient-to-r"); + expect(actionsCell).toHaveClass("before:from-transparent"); + expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary"); + }); + }); }); diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index dba6a450e6..b31092e3e2 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -44,6 +44,18 @@ type DataTableRowAttributes = { * to allow them to flex naturally within the table layout. */ 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 getStickyActionColumnClassName = ( + columnId: string, + variant: "header" | "cell", +) => { + if (columnId !== ACTIONS_COLUMN_ID) return undefined; + + return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS; +}; interface DataTableProviderProps { columns: ColumnDef[]; @@ -286,16 +298,21 @@ export function DataTable({ {headerGroup.headers.map((header) => { const size = header.getSize(); + const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID; return ( - {header.isPlaceholder + {header.isPlaceholder || isActionsHeader ? null : flexRender( header.column.columnDef.header, @@ -323,13 +340,19 @@ export function DataTable({ handleRowClick(row, event.target as HTMLElement) } > {row.getVisibleCells().map((cell) => ( - + {flexRender( cell.column.columnDef.cell, cell.getContext(), diff --git a/ui/lib/date-utils.ts b/ui/lib/date-utils.ts index 7bce896580..894ff9a513 100644 --- a/ui/lib/date-utils.ts +++ b/ui/lib/date-utils.ts @@ -22,6 +22,41 @@ export function toLocalDateString( } } +/** Local date in the app's table format (e.g. "Jun 15, 2026"), as shown by DateWithTime. */ +export function formatLocalDate( + value: string | null | undefined, +): string | undefined { + if (!value) return undefined; + try { + const date = parseISO(value); + if (isNaN(date.getTime())) return undefined; + return format(date, "MMM dd, yyyy"); + } catch { + return undefined; + } +} + +/** Local time with the browser's short zone label (e.g. "12:00AM MAD"), as shown by DateWithTime. */ +export function formatLocalTimeWithZone( + value: string | null | undefined, +): string | undefined { + if (!value) return undefined; + try { + const date = parseISO(value); + if (isNaN(date.getTime())) return undefined; + const zone = + Intl.DateTimeFormat() + .resolvedOptions() + .timeZone.split("/") + .pop() + ?.substring(0, 3) + .toUpperCase() || ""; + return `${format(date, "h:mma")} ${zone}`.trim(); + } catch { + return undefined; + } +} + /** * Formats a duration in seconds to a human-readable string like "2h 5m 30s". */ diff --git a/ui/lib/helper.test.ts b/ui/lib/helper.test.ts index bfe3796453..f2de6e9145 100644 --- a/ui/lib/helper.test.ts +++ b/ui/lib/helper.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { downloadScanZip } from "./helper"; +import { downloadScanZip, getErrorMessage } from "./helper"; vi.mock("@/actions/scans", () => ({ getComplianceCsv: vi.fn(), @@ -118,3 +118,20 @@ describe("downloadScanZip", () => { }); }); }); + +describe("getErrorMessage", () => { + it("does not expose HTML gateway pages", () => { + // Given + const error = new Error( + "502 Bad Gateway

502 Bad Gateway

", + ); + + // When + const message = getErrorMessage(error); + + // Then + expect(message).toBe( + "Server is temporarily unavailable. Please try again in a few minutes.", + ); + }); +}); diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 95173dfeef..9674ea0d73 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -109,6 +109,25 @@ export const getAuthUrl = (provider: AuthSocialProvider) => { const REPORT_PREPARATION_ERROR = "Unable to prepare the scan report. Please try again in a few minutes."; +export const GENERIC_SERVER_ERROR_MESSAGE = + "Server is temporarily unavailable. Please try again in a few minutes."; + +const HTML_ERROR_PATTERN = + /(?: { + const trimmedMessage = message.trim(); + + if (!trimmedMessage) { + return fallback; + } + + return HTML_ERROR_PATTERN.test(trimmedMessage) ? fallback : trimmedMessage; +}; + const getPreflightErrorMessage = async (response: Response) => { const contentType = response.headers.get("content-type")?.toLowerCase() || ""; @@ -404,11 +423,11 @@ export function decryptKey(passkey: string) { export const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { - return error.message; + return sanitizeErrorMessage(error.message); } else if (error && typeof error === "object" && "message" in error) { - return String(error.message); + return sanitizeErrorMessage(String(error.message)); } else if (typeof error === "string") { - return error; + return sanitizeErrorMessage(error); } else { return "Oops! Something went wrong."; } diff --git a/ui/lib/schedules.test.ts b/ui/lib/schedules.test.ts new file mode 100644 index 0000000000..4004de0ee1 --- /dev/null +++ b/ui/lib/schedules.test.ts @@ -0,0 +1,490 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + buildScheduleUpdatePayload, + formatScheduleHour, + getBrowserTimezone, + getNextScheduledRun, + getScanScheduleCapability, + getScheduleFormValues, + isScheduleConfigured, +} from "@/lib/schedules"; +import { + SCAN_SCHEDULE_CAPABILITY, + SCHEDULE_FREQUENCY, + type ScheduleAttributes, + type ScheduleProps, +} from "@/types/schedules"; + +describe("schedule payload mapping", () => { + beforeEach(() => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), + } as Intl.DateTimeFormat); + }); + + it("maps daily schedules and clears unused fields", () => { + // Given + const values = { + frequency: SCHEDULE_FREQUENCY.DAILY, + hour: 8, + dayOfWeek: 2, + dayOfMonth: 12, + intervalHours: 48, + launchInitialScan: false, + }; + + // When + const payload = buildScheduleUpdatePayload(values); + + // Then + expect(payload).toEqual({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 8, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + }); + }); + + it("maps every 48 hours schedules as an interval", () => { + // Given + const values = { + frequency: SCHEDULE_FREQUENCY.INTERVAL, + hour: 23, + dayOfWeek: 0, + dayOfMonth: 1, + intervalHours: 48, + launchInitialScan: false, + }; + + // When + const payload = buildScheduleUpdatePayload(values); + + // Then + expect(payload).toEqual({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, + scan_hour: 23, + scan_timezone: "Europe/Madrid", + scan_interval_hours: 48, + scan_day_of_week: null, + scan_day_of_month: null, + }); + }); + + it("preserves a custom interval instead of rewriting it to 48 hours", () => { + // Given a schedule whose interval was set outside the UI (e.g. bulk API) + const values = { + frequency: SCHEDULE_FREQUENCY.INTERVAL, + hour: 5, + dayOfWeek: 0, + dayOfMonth: 1, + intervalHours: 72, + launchInitialScan: false, + }; + + // When + const payload = buildScheduleUpdatePayload(values); + + // Then + expect(payload.scan_interval_hours).toBe(72); + }); + + it("maps weekly schedules with 0 as Sunday and clears interval/month", () => { + // Given + const values = { + frequency: SCHEDULE_FREQUENCY.WEEKLY, + hour: 6, + dayOfWeek: 0, + dayOfMonth: 28, + intervalHours: 48, + launchInitialScan: false, + }; + + // When + const payload = buildScheduleUpdatePayload(values); + + // Then + expect(payload).toEqual({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 6, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: 0, + scan_day_of_month: null, + }); + }); + + it("maps monthly schedules and keeps scan_hour 0 (not falsy-coerced)", () => { + // Given + const values = { + frequency: SCHEDULE_FREQUENCY.MONTHLY, + hour: 0, + dayOfWeek: 5, + dayOfMonth: 28, + intervalHours: 48, + launchInitialScan: false, + }; + + // When + const payload = buildScheduleUpdatePayload(values); + + // Then + expect(payload).toEqual({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, + scan_hour: 0, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: 28, + }); + }); +}); + +describe("formatScheduleHour", () => { + it.each([ + [0, "12:00am"], + [12, "12:00pm"], + [13, "1:00pm"], + [23, "11:00pm"], + [-1, "11:00pm"], + [24, "12:00am"], + ])("formats hour %i as %s", (hour, expected) => { + expect(formatScheduleHour(hour)).toBe(expected); + }); +}); + +describe("isScheduleConfigured", () => { + it("treats a null scan_hour as not configured", () => { + expect(isScheduleConfigured({ scan_hour: null })).toBe(false); + }); + + it("treats scan_hour 0 (midnight) as configured", () => { + expect(isScheduleConfigured({ scan_hour: 0 })).toBe(true); + }); + + it("treats a set scan_hour as configured", () => { + expect(isScheduleConfigured({ scan_hour: 14 })).toBe(true); + }); +}); + +describe("getScheduleFormValues", () => { + const buildAttributes = ( + overrides: Partial = {}, + ): ScheduleAttributes => ({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: 4, + scan_day_of_month: 20, + ...overrides, + }); + + it("returns defaults when there is no schedule", () => { + expect(getScheduleFormValues(null)).toEqual({ + frequency: SCHEDULE_FREQUENCY.DAILY, + hour: 0, + dayOfWeek: 1, + dayOfMonth: 1, + intervalHours: 48, + launchInitialScan: false, + }); + }); + + it("returns defaults when scan_hour is null (unconfigured provider)", () => { + expect(getScheduleFormValues(buildAttributes({ scan_hour: null }))).toEqual( + { + frequency: SCHEDULE_FREQUENCY.DAILY, + hour: 0, + dayOfWeek: 1, + dayOfMonth: 1, + intervalHours: 48, + launchInitialScan: false, + }, + ); + }); + + it("maps a configured schedule onto the form", () => { + expect(getScheduleFormValues(buildAttributes())).toEqual({ + frequency: SCHEDULE_FREQUENCY.WEEKLY, + hour: 9, + dayOfWeek: 4, + dayOfMonth: 20, + intervalHours: 48, + launchInitialScan: false, + }); + }); + + it("keeps a custom interval from the stored schedule", () => { + const values = getScheduleFormValues( + buildAttributes({ + scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, + scan_interval_hours: 72, + scan_day_of_week: null, + scan_day_of_month: null, + }), + ); + expect(values.frequency).toBe(SCHEDULE_FREQUENCY.INTERVAL); + expect(values.intervalHours).toBe(72); + }); + + it("falls back to default day fields when the schedule leaves them null", () => { + const values = getScheduleFormValues( + buildAttributes({ scan_day_of_week: null, scan_day_of_month: null }), + ); + expect(values.dayOfWeek).toBe(1); + expect(values.dayOfMonth).toBe(1); + }); +}); + +describe("getNextScheduledRun", () => { + const baseValues = { + frequency: SCHEDULE_FREQUENCY.DAILY, + hour: 14, + dayOfWeek: 5, + dayOfMonth: 15, + intervalHours: 48, + launchInitialScan: false, + }; + // Wednesday 2026-06-10 10:30 local. + const now = new Date(2026, 5, 10, 10, 30, 0, 0); + + const parts = (date: Date) => ({ + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hour: date.getHours(), + }); + + it("DAILY: same day when the hour is still ahead", () => { + expect( + parts(getNextScheduledRun({ ...baseValues, hour: 14 }, now)), + ).toEqual({ year: 2026, month: 5, day: 10, hour: 14 }); + }); + + it("DAILY: next day when the hour already passed", () => { + expect(parts(getNextScheduledRun({ ...baseValues, hour: 8 }, now))).toEqual( + { + year: 2026, + month: 5, + day: 11, + hour: 8, + }, + ); + }); + + it("INTERVAL: anchors at the next occurrence of the hour, like DAILY", () => { + // The backend derives the INTERVAL anchor as today/tomorrow at scan_hour + // and fires the first run there; repeats only start after that. + expect( + parts( + getNextScheduledRun( + { ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 14 }, + now, + ), + ), + ).toEqual({ year: 2026, month: 5, day: 10, hour: 14 }); + + expect( + parts( + getNextScheduledRun( + { ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 8 }, + now, + ), + ), + ).toEqual({ year: 2026, month: 5, day: 11, hour: 8 }); + }); + + it("WEEKLY: advances to the target weekday this week", () => { + expect( + parts( + getNextScheduledRun( + { + ...baseValues, + frequency: SCHEDULE_FREQUENCY.WEEKLY, + dayOfWeek: 5, + hour: 9, + }, + now, + ), + ), + ).toEqual({ year: 2026, month: 5, day: 12, hour: 9 }); + }); + + it("WEEKLY: jumps a week when the target day/hour already passed today", () => { + expect( + parts( + getNextScheduledRun( + { + ...baseValues, + frequency: SCHEDULE_FREQUENCY.WEEKLY, + dayOfWeek: 3, + hour: 8, + }, + now, + ), + ), + ).toEqual({ year: 2026, month: 5, day: 17, hour: 8 }); + }); + + it("MONTHLY: this month when the day is still ahead", () => { + expect( + parts( + getNextScheduledRun( + { + ...baseValues, + frequency: SCHEDULE_FREQUENCY.MONTHLY, + dayOfMonth: 15, + }, + now, + ), + ), + ).toEqual({ year: 2026, month: 5, day: 15, hour: 14 }); + }); + + it("MONTHLY: next month when the day already passed", () => { + expect( + parts( + getNextScheduledRun( + { + ...baseValues, + frequency: SCHEDULE_FREQUENCY.MONTHLY, + dayOfMonth: 5, + }, + now, + ), + ), + ).toEqual({ year: 2026, month: 6, day: 5, hour: 14 }); + }); +}); + +describe("browser timezone", () => { + it("falls back to UTC when browser timezone is unavailable", () => { + // Given + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({}), + } as Intl.DateTimeFormat); + + // When / Then + expect(getBrowserTimezone()).toBe("UTC"); + }); +}); + +describe("scan schedule capability", () => { + it("returns DAILY_LEGACY for non-Cloud (OSS)", () => { + expect(getScanScheduleCapability(false)).toBe( + SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY, + ); + }); + + it("returns ADVANCED for Cloud", () => { + expect(getScanScheduleCapability(true)).toBe( + SCAN_SCHEDULE_CAPABILITY.ADVANCED, + ); + }); +}); + +describe("buildSchedulesByProviderId", () => { + const buildSchedule = ( + id: string, + overrides: Partial = {}, + ): ScheduleProps => ({ + type: "schedules", + id, + attributes: { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + ...overrides, + }, + relationships: { + provider: { data: { type: "providers", id } }, + }, + }); + + it("indexes schedule attributes by provider id (the schedule's own id)", () => { + const result = { + data: [ + buildSchedule("provider-1", { scan_hour: 6 }), + buildSchedule("provider-2", { scan_hour: null }), + ], + }; + + expect(buildSchedulesByProviderId(result)).toEqual({ + "provider-1": result.data[0].attributes, + "provider-2": result.data[1].attributes, + }); + }); + + it("returns an empty map when the request errored (e.g. OSS without /schedules)", () => { + expect(buildSchedulesByProviderId({ error: "Not found" })).toEqual({}); + }); + + it("returns an empty map for a null/undefined result", () => { + expect(buildSchedulesByProviderId(null)).toEqual({}); + expect(buildSchedulesByProviderId(undefined)).toEqual({}); + }); +}); + +describe("buildProviderScheduleSummary", () => { + const buildAttributes = ( + overrides: Partial = {}, + ): ScheduleAttributes => ({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + ...overrides, + }); + + const now = new Date(2026, 5, 10, 10, 30, 0, 0); + + it.each([ + [{ scan_frequency: SCHEDULE_FREQUENCY.DAILY }, "Daily"], + [ + { scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, scan_day_of_week: 1 }, + "Weekly on Monday", + ], + [ + { scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 }, + "Monthly on day 15", + ], + [ + { scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 }, + "Every 72 hours", + ], + ])("exposes the %o cadence as %s", (overrides, cadence) => { + expect( + buildProviderScheduleSummary(buildAttributes(overrides), now).cadence, + ).toBe(cadence); + }); + + it("passes through server-computed next/last run timestamps", () => { + const summary = buildProviderScheduleSummary( + buildAttributes({ + next_scan_at: "2026-06-15T07:00:00Z", + last_scan_at: "2026-06-08T07:00:00Z", + }), + now, + ); + + expect(summary.nextScanAt).toBe("2026-06-15T07:00:00Z"); + expect(summary.lastScanAt).toBe("2026-06-08T07:00:00Z"); + }); +}); diff --git a/ui/lib/schedules.ts b/ui/lib/schedules.ts new file mode 100644 index 0000000000..1117ababe1 --- /dev/null +++ b/ui/lib/schedules.ts @@ -0,0 +1,290 @@ +import { z } from "zod"; + +import type { ScanScheduleSummary } from "@/types/scans"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + SCHEDULE_FREQUENCY, + SCHEDULE_WEEKDAY_LABELS, + type ScheduleAttributes, + type ScheduleFormValues, + type ScheduleProps, + type ScheduleUpdatePayload, +} from "@/types/schedules"; + +const DEFAULT_SCHEDULE_HOUR = 0; +const DEFAULT_DAY_OF_WEEK = 1; +const DEFAULT_DAY_OF_MONTH = 1; +// The backend (prowler-cloud) enforces SCAN_INTERVAL_HOURS_MIN = 24. 48 is well +// above that floor. +const SCAN_INTERVAL_HOURS_MIN = 24; +const DEFAULT_INTERVAL_HOURS = 48; + +export const scheduleFormSchema = z.object({ + frequency: z.enum(SCHEDULE_FREQUENCY), + hour: z.number().int().min(0).max(23), + dayOfWeek: z.number().int().min(0).max(6), + dayOfMonth: z.number().int().min(1).max(28), + intervalHours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN), + launchInitialScan: z.boolean(), +}); + +/** + * Default scan-schedule capability for the current environment. + * + * Pure function (no side effects) so it is trivial to unit-test. Prowler OSS has + * no billing, so the only distinction it can make is Cloud vs non-Cloud: + * non-Cloud → legacy daily-only, Cloud → full scheduling. The prowler-cloud + * overlay computes its own (billing-aware) capability and passes it down via the + * optional `capability` prop, overriding this default — no billing concept ever + * leaks into OSS. + */ +export function getScanScheduleCapability( + isCloud: boolean, +): ScanScheduleCapability { + return isCloud + ? SCAN_SCHEDULE_CAPABILITY.ADVANCED + : SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; +} + +export function formatScheduleHour(hour: number): string { + const normalizedHour = ((hour % 24) + 24) % 24; + const period = normalizedHour >= 12 ? "pm" : "am"; + const displayHour = normalizedHour % 12 === 0 ? 12 : normalizedHour % 12; + + return `${displayHour}:00${period}`; +} + +export function getBrowserTimezone(): string { + if (typeof window === "undefined") { + return "UTC"; + } + + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} + +export function getScheduleFormDefaults(): ScheduleFormValues { + return { + frequency: SCHEDULE_FREQUENCY.DAILY, + hour: DEFAULT_SCHEDULE_HOUR, + dayOfWeek: DEFAULT_DAY_OF_WEEK, + dayOfMonth: DEFAULT_DAY_OF_MONTH, + intervalHours: DEFAULT_INTERVAL_HOURS, + launchInitialScan: false, + }; +} + +export function getScheduleFormValues( + schedule?: ScheduleAttributes | null, +): ScheduleFormValues { + const defaults = getScheduleFormDefaults(); + + if (!schedule || schedule.scan_hour === null) { + return defaults; + } + + return { + frequency: schedule.scan_frequency, + hour: schedule.scan_hour, + dayOfWeek: schedule.scan_day_of_week ?? defaults.dayOfWeek, + dayOfMonth: schedule.scan_day_of_month ?? defaults.dayOfMonth, + intervalHours: schedule.scan_interval_hours ?? defaults.intervalHours, + launchInitialScan: false, + }; +} + +export function buildScheduleUpdatePayload( + values: ScheduleFormValues, +): ScheduleUpdatePayload { + return { + scan_enabled: true, + scan_frequency: values.frequency, + scan_hour: values.hour, + scan_timezone: getBrowserTimezone(), + scan_interval_hours: + values.frequency === SCHEDULE_FREQUENCY.INTERVAL + ? values.intervalHours + : null, + scan_day_of_week: + values.frequency === SCHEDULE_FREQUENCY.WEEKLY ? values.dayOfWeek : null, + scan_day_of_month: + values.frequency === SCHEDULE_FREQUENCY.MONTHLY + ? values.dayOfMonth + : null, + }; +} + +interface SchedulesActionResult { + data?: ScheduleProps[] | null; + error?: unknown; +} + +/** + * Indexes a `getSchedules()` result by provider id — the schedule resource's own + * `id` IS the provider id. Returns an empty map on error or missing data, so + * callers can treat schedules as best-effort (e.g. OSS, where the `/schedules` + * list endpoint may not exist). Shared by the scans and providers views. + */ +export function buildSchedulesByProviderId( + result: SchedulesActionResult | null | undefined, +): Record { + const byProviderId: Record = {}; + if (!result || result.error) return byProviderId; + + for (const schedule of result.data ?? []) { + byProviderId[schedule.id] = schedule.attributes; + } + + return byProviderId; +} + +/** + * Whether a provider has an explicitly configured scan schedule. + * + * The schedule resource is backed by the Provider row itself: an unconfigured + * provider — or one whose schedule was removed (DELETE resets the schedule to + * defaults: `scan_hour=null`, but leaves `scan_enabled=true`) — comes back with + * `scan_hour === null`. So `scan_hour` is the canonical "is configured" signal; + * `scan_enabled` is NOT, because a freshly created provider already reports + * `scan_enabled=true`. + */ +export function isScheduleConfigured( + attributes: Pick, +): boolean { + return attributes.scan_hour !== null; +} + +/** + * Computes the next time a schedule would run, as a local `Date`. Pure: `now` is + * injected so it is deterministic to test. Computation is done in the browser's + * local time, which matches the timezone shown next to it (`getBrowserTimezone`), + * so no timezone-conversion library is needed. This is an estimate for display; + * the backend is the source of truth for the actual fire time. + * + * INTERVAL shares the DAILY computation: the backend anchors its first run at + * the next occurrence of `scan_hour`. + */ +export function getNextScheduledRun( + values: ScheduleFormValues, + now: Date, +): Date { + const next = new Date(now); + next.setHours(values.hour, 0, 0, 0); + + switch (values.frequency) { + case SCHEDULE_FREQUENCY.WEEKLY: { + let delta = (values.dayOfWeek - next.getDay() + 7) % 7; + if (delta === 0 && next <= now) delta = 7; + next.setDate(next.getDate() + delta); + return next; + } + case SCHEDULE_FREQUENCY.MONTHLY: { + next.setDate(values.dayOfMonth); + if (next <= now) { + next.setMonth(next.getMonth() + 1, values.dayOfMonth); + } + return next; + } + default: { + // DAILY and INTERVAL (the interval anchor is the next occurrence of the hour) + if (next <= now) next.setDate(next.getDate() + 1); + return next; + } + } +} + +export interface ScheduleCadenceParts { + /** e.g. "Weekly on Monday" */ + cadence: string; + /** e.g. "9:00am (Europe/Madrid)" */ + time: string; +} + +export function getScheduleCadenceParts( + attributes: ScheduleAttributes, +): ScheduleCadenceParts { + const time = `${formatScheduleHour(attributes.scan_hour ?? 0)} (${attributes.scan_timezone})`; + + switch (attributes.scan_frequency) { + case SCHEDULE_FREQUENCY.WEEKLY: { + const weekday = + SCHEDULE_WEEKDAY_LABELS[attributes.scan_day_of_week ?? 0] ?? + SCHEDULE_WEEKDAY_LABELS[0]; + return { cadence: `Weekly on ${weekday}`, time }; + } + case SCHEDULE_FREQUENCY.MONTHLY: + return { + cadence: `Monthly on day ${attributes.scan_day_of_month ?? 1}`, + time, + }; + case SCHEDULE_FREQUENCY.INTERVAL: + return { + cadence: `Every ${attributes.scan_interval_hours ?? 0} hours`, + time, + }; + default: + return { cadence: "Daily", time }; + } +} + +/** Human-readable cadence, e.g. "Weekly on Monday @ 9:00am (Europe/Madrid)". */ +export function describeScheduleCadence( + attributes: ScheduleAttributes, +): string { + const { cadence, time } = getScheduleCadenceParts(attributes); + return `${cadence} @ ${time}`; +} + +/** + * Next-run estimate honoring the schedule's own timezone: `toLocaleString` + * converts `now` to that timezone's wall-clock and the offset is applied back. + */ +export function getNextScheduledRunInTimezone( + attributes: ScheduleAttributes, + now: Date, +): Date | null { + if (attributes.scan_hour === null) return null; + + let timezoneNow: Date; + try { + timezoneNow = new Date( + now.toLocaleString("en-US", { timeZone: attributes.scan_timezone }), + ); + } catch { + timezoneNow = new Date(now); + } + if (Number.isNaN(timezoneNow.getTime())) timezoneNow = new Date(now); + + const target = getNextScheduledRun( + getScheduleFormValues(attributes), + timezoneNow, + ); + + return new Date(now.getTime() + (target.getTime() - timezoneNow.getTime())); +} + +/** + * Builds the display summary (cadence + next/last run) for a provider's + * schedule, the way the scans and providers tables render it. Shared so both + * views show identical cadence labels. + * + * `next_scan_at` semantics: an absent field (older API) with an enabled + * schedule falls back to a client estimate; an explicit null (paused) means no + * next run. + */ +export function buildProviderScheduleSummary( + attributes: ScheduleAttributes, + now: Date, +): ScanScheduleSummary { + const nextScanAt = + attributes.next_scan_at === undefined && attributes.scan_enabled + ? (getNextScheduledRunInTimezone(attributes, now)?.toISOString() ?? null) + : (attributes.next_scan_at ?? null); + + return { + summary: describeScheduleCadence(attributes), + cadence: getScheduleCadenceParts(attributes).cadence, + nextScanAt, + lastScanAt: attributes.last_scan_at ?? null, + }; +} diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts new file mode 100644 index 0000000000..bfb9fb5ced --- /dev/null +++ b/ui/lib/server-actions-helper.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { captureExceptionMock, captureMessageMock, revalidatePathMock } = + vi.hoisted(() => ({ + captureExceptionMock: vi.fn(), + captureMessageMock: vi.fn(), + revalidatePathMock: vi.fn(), + })); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, + captureMessage: captureMessageMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("./helper", () => ({ + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), + parseStringify: (value: unknown) => value, + sanitizeErrorMessage: (message: string, fallback: string) => + / { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws a generic server error instead of raw HTML for 5xx responses", async () => { + // Given + const response = new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + + // When / Then + const result = handleApiResponse(response); + await expect(result).rejects.toThrow( + "Server is temporarily unavailable. Please try again in a few minutes.", + ); + }); +}); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index e368d10294..ae677f2ed2 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache"; import { SentryErrorSource, SentryErrorType } from "@/sentry"; -import { getErrorMessage, parseStringify } from "./helper"; +import { + GENERIC_SERVER_ERROR_MESSAGE, + getErrorMessage, + parseStringify, + sanitizeErrorMessage, +} from "./helper"; /** * Helper function to handle API responses consistently @@ -17,6 +22,7 @@ export const handleApiResponse = async ( if (!response.ok) { // Read error body safely; prefer JSON, fallback to plain text const rawErrorText = await response.text().catch(() => ""); + const contentType = response.headers.get("content-type")?.toLowerCase(); let errorData: any = null; try { errorData = rawErrorText ? JSON.parse(rawErrorText) : null; @@ -27,13 +33,20 @@ export const handleApiResponse = async ( const errorsArray = Array.isArray(errorData?.errors) ? (errorData.errors as any[]) : undefined; - const errorDetail = - errorsArray?.[0]?.detail || - errorData?.error || - errorData?.message || - (rawErrorText && rawErrorText.trim()) || - response.statusText || - "Oops! Something went wrong."; + const parsedErrorMessage = + errorsArray?.[0]?.detail || errorData?.error || errorData?.message; + const fallbackErrorMessage = + response.status >= 500 || contentType?.includes("text/html") + ? GENERIC_SERVER_ERROR_MESSAGE + : response.statusText || "Oops! Something went wrong."; + const rawErrorMessage = + parsedErrorMessage || + (response.status < 500 && rawErrorText.trim()) || + fallbackErrorMessage; + const errorDetail = sanitizeErrorMessage( + String(rawErrorMessage), + fallbackErrorMessage, + ); // Capture error context for Sentry const errorContext = { diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index dd4692ed57..67d647448f 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -5,7 +5,6 @@ import { AWS_CREDENTIAL_OPTIONS, ProvidersPage, } from "./providers/providers-page"; -import { ScansPage } from "./scans/scans-page"; export const ERROR_MESSAGES = { INVALID_CREDENTIALS: "Invalid email or password", @@ -123,13 +122,10 @@ export async function addAWSProvider( await providersPage.fillStaticCredentials(staticCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to provider page - const scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); + // Scans specs launch their own scan. The setup helper should only leave a + // connected provider behind so the suite does not spend CI capacity on a + // duplicate preparatory scan. + await providersPage.completeProviderConnectionWithoutLaunchingScan(accountId); } /** @@ -223,9 +219,13 @@ export async function deleteProviderIfExists( await expect(deleteMenuItem).toBeVisible({ timeout: 5000 }); await deleteMenuItem.click(); - // Wait for confirmation modal to appear + // Wait for confirmation modal to appear. Exclude the Next.js dev error + // overlay, which is also role="dialog" and would otherwise be matched first, + // making the assertion wait on the wrong (hidden) element. const modal = page.page - .locator('[role="dialog"], .modal, [data-testid*="modal"]') + .locator( + '[role="dialog"]:not([data-nextjs-dialog="true"]), .modal, [data-testid*="modal"]', + ) .first(); await expect(modal).toBeVisible({ timeout: 10000 }); diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 5aeb3723a6..f618c59d97 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -880,7 +880,9 @@ export class ProvidersPage extends BasePage { if (actionName === "Check connection") { await this.handleCheckConnectionCompletion(); } - if (actionName === "Launch scan") { + // "Save" is the wizard's final action (saves the scan schedule) and + // "Launch scan" its legacy/manual counterpart; both close the modal. + if (actionName === "Launch scan" || actionName === "Save") { await this.handleLaunchScanCompletion(); } return; @@ -893,6 +895,12 @@ export class ProvidersPage extends BasePage { } private async handleCheckConnectionCompletion(): Promise { + // A successful connection advances to the schedule step ("Save"); older + // flows surfaced a "Launch scan" action instead. + const saveScheduleButton = this.page.getByRole("button", { + name: "Save", + exact: true, + }); const launchScanButton = this.page.getByRole("button", { name: "Launch scan", exact: true, @@ -903,6 +911,7 @@ export class ProvidersPage extends BasePage { try { await Promise.race([ + saveScheduleButton.waitFor({ state: "visible", timeout: 30000 }), launchScanButton.waitFor({ state: "visible", timeout: 30000 }), this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }), connectionError.waitFor({ state: "visible", timeout: 30000 }), @@ -918,18 +927,103 @@ export class ProvidersPage extends BasePage { ); } - if (await launchScanButton.isVisible().catch(() => false)) { + if (await saveScheduleButton.isVisible().catch(() => false)) { + await saveScheduleButton.click(); + await this.handleLaunchScanCompletion(); + } else if (await launchScanButton.isVisible().catch(() => false)) { await launchScanButton.click(); await this.handleLaunchScanCompletion(); } } + private async waitForProviderLaunchChoice(): Promise { + const launchAction = this.page + .getByRole("button", { name: "Save", exact: true }) + .or(this.page.getByRole("button", { name: "Launch scan", exact: true })); + const connectionError = this.page.locator( + "div.border-border-error p.text-text-error-primary", + ); + + try { + await Promise.race([ + launchAction.waitFor({ state: "visible", timeout: 30000 }), + connectionError.waitFor({ state: "visible", timeout: 30000 }), + ]); + } catch { + // Continue and inspect visible state below. + } + + if (await connectionError.isVisible().catch(() => false)) { + const errorText = await connectionError.textContent(); + throw new Error( + `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, + ); + } + + await expect(launchAction).toBeVisible(); + } + + async completeProviderConnectionWithoutLaunchingScan( + providerUID: string, + ): Promise { + await this.verifyWizardModalOpen(); + + const checkConnectionButton = this.page.getByRole("button", { + name: "Check connection", + exact: true, + }); + const launchAction = this.page + .getByRole("button", { name: "Save", exact: true }) + .or(this.page.getByRole("button", { name: "Launch scan", exact: true })); + const connectionError = this.page.locator( + "div.border-border-error p.text-text-error-primary", + ); + + // The test-connection step renders its footer action only after an async + // load (canSubmit gate). Wait for the footer to settle on an actionable + // state (or surface a connection error) instead of reading visibility on + // the first frame, which races the render and falls through. + await expect( + checkConnectionButton.or(launchAction).or(connectionError), + ).toBeVisible({ timeout: 30000 }); + + if (await connectionError.isVisible().catch(() => false)) { + const errorText = await connectionError.textContent(); + throw new Error( + `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, + ); + } + + // Provider-add E2E validates credentials and provider persistence only. + // Launching one scan per provider made CI noisy and overloaded the backend; + // scan execution itself is covered by scans.spec.ts. + if (await checkConnectionButton.isVisible().catch(() => false)) { + await checkConnectionButton.click(); + await this.waitForProviderLaunchChoice(); + } else { + await expect(launchAction).toBeVisible(); + } + + await this.wizardModal + .getByRole("button", { name: "Close", exact: true }) + .click(); + await expect(this.wizardModal).not.toBeVisible({ timeout: 30000 }); + await this.page.waitForURL(/\/providers/, { timeout: 30000 }); + await this.verifyLoadProviderPageAfterNewProvider(); + + const providerExists = + await this.verifySingleRowForProviderUID(providerUID); + if (!providerExists) { + throw new Error(`Provider with UID ${providerUID} was not found.`); + } + } + private async handleLaunchScanCompletion(): Promise { const connectionError = this.page.locator( "div.border-border-error p.text-text-error-primary", ); const launchErrorToast = this.page.getByRole("alert").filter({ - hasText: /Unable to launch scan/i, + hasText: /Unable to (launch scan|save scan schedule)/i, }); try { @@ -1525,9 +1619,11 @@ export class ProvidersPage extends BasePage { await this.verifyPageHasProwlerTitle(); await this.verifyWizardModalOpen(); - // Some providers show "Check connection" before "Launch scan". + // The final step saves the scan schedule ("Save"); manual-only accounts + // show "Launch scan" and some providers show "Check connection" first. const launchAction = this.page - .getByRole("button", { name: "Launch scan", exact: true }) + .getByRole("button", { name: "Save", exact: true }) + .or(this.page.getByRole("button", { name: "Launch scan", exact: true })) .or( this.page.getByRole("button", { name: "Check connection", @@ -1555,10 +1651,14 @@ export class ProvidersPage extends BasePage { hasText: providerUID, }); - // Verify the number of matching rows is 1 - const count = await matchingRows.count(); - if (count !== 1) return false; - return true; + // Use an auto-retrying assertion (not an instant count()) so the check + // waits for the table refetch to render the newly added provider row. + try { + await expect(matchingRows).toHaveCount(1, { timeout: 15000 }); + return true; + } catch { + return false; + } } async selectAuthenticationMethod(method: AWSCredentialType): Promise { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 944638a75a..e8139b2b3d 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -32,26 +32,26 @@ 4. Fill provider details (account ID and alias) 5. Select "credentials" authentication type 6. Fill static credentials (access key and secret key) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - AWS provider successfully added with static credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points - Provider page loads correctly - Connect account page displays AWS option - Credentials form accepts static credentials -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by account ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by account ID) +- Provider UID matches the expected value ### Notes @@ -88,26 +88,26 @@ 4. Fill provider details (account ID and alias) 5. Select "role" authentication type 6. Fill role credentials (access key, secret key, and role ARN) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - AWS provider successfully added with role credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points - Provider page loads correctly - Connect account page displays AWS option - Role credentials form accepts all required fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by account ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by account ID) +- Provider UID matches the expected value ### Notes @@ -144,26 +144,26 @@ 3. Select Azure provider type 4. Fill provider details (subscription ID and alias) 5. Fill Azure credentials (client ID, client secret, tenant ID) -6. Launch initial scan -7. Verify redirect to Scans page -8. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +6. Confirm provider connection without launching a scan +7. Verify return to Providers page +8. Verify provider exists in Providers table ### Expected Result - Azure provider successfully added with static credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points - Provider page loads correctly - Connect account page displays Azure option - Azure credentials form accepts all required fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by subscription ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by subscription ID) +- Provider UID matches the expected value ### Notes @@ -201,26 +201,26 @@ 4. Fill provider details (domain ID and alias) 5. Select static credentials type 6. Fill M365 credentials (client ID, client secret, tenant ID) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - M365 provider successfully added with static credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points - Provider page loads correctly - Connect account page displays M365 option - M365 credentials form accepts all required fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by domain ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by domain ID) +- Provider UID matches the expected value ### Notes @@ -258,26 +258,26 @@ 4. Fill provider details (domain ID and alias) 5. Select certificate credentials type 6. Fill M365 certificate credentials (client ID, tenant ID, certificate content) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - M365 provider successfully added with certificate credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points - Provider page loads correctly - Connect account page displays M365 option - Certificate credentials form accepts all required fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by domain ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by domain ID) +- Provider UID matches the expected value ### Notes @@ -316,16 +316,16 @@ 4. Fill provider details (context and alias) 5. Verify credentials page is loaded 6. Fill Kubernetes credentials (kubeconfig content) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - Kubernetes provider successfully added with kubeconfig content -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -334,10 +334,10 @@ - Provider details form accepts context and alias - Credentials page loads with kubeconfig content field - Kubeconfig content is properly filled in the correct field -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by context) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by context) +- Provider UID matches the expected value ### Notes @@ -377,16 +377,16 @@ 4. Fill provider details (project ID and alias) 5. Select service account credentials type 6. Fill GCP service account key credentials -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - GCP provider successfully added with service account key -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -395,10 +395,10 @@ - Provider details form accepts project ID and alias - Service account credentials page loads with service account key field - Service account key is properly filled in the correct field -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by project ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by project ID) +- Provider UID matches the expected value ### Notes @@ -439,16 +439,16 @@ 4. Fill provider details (username and alias) 5. Select personal access token credentials type 6. Fill GitHub personal access token credentials -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - GitHub provider successfully added with personal access token -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -457,10 +457,10 @@ - Provider details form accepts username and alias - Personal access token credentials page loads with token field - Personal access token is properly filled in the correct field -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by username) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by username) +- Provider UID matches the expected value ### Notes @@ -499,16 +499,16 @@ 4. Fill provider details (username and alias) 5. Select GitHub App credentials type 6. Fill GitHub App credentials (App ID and private key) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - GitHub provider successfully added with GitHub App credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -517,10 +517,10 @@ - Provider details form accepts username and alias - GitHub App credentials page loads with App ID and private key fields - GitHub App credentials are properly filled in the correct fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by username) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by username) +- Provider UID matches the expected value ### Notes @@ -560,16 +560,16 @@ 4. Fill provider details (organization name and alias) 5. Select personal access token credentials type 6. Fill GitHub organization personal access token credentials -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - GitHub provider successfully added with organization personal access token -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -578,10 +578,10 @@ - Provider details form accepts organization name and alias - Personal access token credentials page loads with token field - Organization personal access token is properly filled in the correct field -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by organization name) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by organization name) +- Provider UID matches the expected value ### Notes @@ -621,16 +621,16 @@ 5. Select "role" authentication type 6. Switch authentication method to "Use AWS SDK default credentials" 7. Fill role ARN using AWS SDK credential inputs -8. Launch initial scan -9. Verify redirect to Scans page -10. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +8. Confirm provider connection without launching a scan +9. Verify return to Providers page +10. Verify provider exists in Providers table ### Expected Result - AWS provider successfully added using AWS SDK default credentials to assume the role -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -638,10 +638,10 @@ - Connect account page displays AWS option - Credentials form exposes AWS SDK default authentication method - Role ARN field accepts provided value when SDK method is selected -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by account ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by account ID) +- Provider UID matches the expected value ### Notes @@ -679,16 +679,16 @@ 4. Fill provider details (tenancy ID and alias) 5. Verify OCI credentials page is loaded 6. Fill OCI credentials (user ID, fingerprint, key content, region) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - OCI provider successfully added with API Key credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -697,10 +697,10 @@ - Provider details form accepts tenancy ID and alias - OCI credentials page loads - Credentials form accepts all required fields (user ID, fingerprint, key content, region) -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by tenancy ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by tenancy ID) +- Provider UID matches the expected value ### Notes @@ -798,16 +798,16 @@ 6. Select static credentials type 7. Verify static credentials page is loaded 8. Fill AlibabaCloud credentials (access key ID and access key secret) -9. Launch initial scan -10. Verify redirect to Scans page -11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +9. Confirm provider connection without launching a scan +10. Verify return to Providers page +11. Verify provider exists in Providers table ### Expected Result - AlibabaCloud provider successfully added with static credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -817,10 +817,10 @@ - Credentials page loads with credential type selection - Static credentials page loads with access key ID and access key secret fields - Static credentials are properly filled in the correct fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by account ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by account ID) +- Provider UID matches the expected value ### Notes @@ -860,16 +860,16 @@ 6. Select RAM Role credentials type 7. Verify RAM Role credentials page is loaded 8. Fill AlibabaCloud RAM Role credentials (access key ID, access key secret, and role ARN) -9. Launch initial scan -10. Verify redirect to Scans page -11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +9. Confirm provider connection without launching a scan +10. Verify return to Providers page +11. Verify provider exists in Providers table ### Expected Result - AlibabaCloud provider successfully added with RAM Role credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -879,10 +879,10 @@ - Credentials page loads with credential type selection - RAM Role credentials page loads with access key ID, access key secret, and role ARN fields - RAM Role credentials are properly filled in the correct fields -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by account ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by account ID) +- Provider UID matches the expected value ### Notes @@ -924,15 +924,15 @@ 6. Continue to authentication details and provide role ARN 7. Confirm StackSet deployment checkbox and authenticate 8. Confirm organization account selection step and continue -9. Verify organization launch step, choose single scan schedule, and launch -10. Verify redirect to Scans page +9. Verify organization launch step, choose daily scan schedule, and save +10. Verify return to Providers page ### Expected Result - AWS Organizations flow completes successfully - Accounts are connected and launch step is displayed - Scan scheduling selection is applied -- User is redirected to Scans page after launch +- User returns to Providers page after saving ### Key verification points @@ -941,7 +941,7 @@ - Authentication details step loads - Account selection step loads - Accounts connected launch step appears -- Successful redirect to Scans page after launching +- Successful return to Providers page after saving ### Notes @@ -978,16 +978,16 @@ 4. Fill provider details (customer ID and alias) 5. Verify Google Workspace credentials page is loaded 6. Fill Google Workspace credentials (customer ID, service account JSON, delegated user email) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - Google Workspace provider successfully added with Service Account credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -998,10 +998,10 @@ - Customer ID help text is visible with instructions on finding the Customer ID - Service account JSON field accepts multi-line formatted JSON - Delegated user email field validates email format -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by customer ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by customer ID) +- Provider UID matches the expected value ### Notes @@ -1042,16 +1042,16 @@ 4. Fill provider details (team ID and alias) 5. Verify Vercel credentials page is loaded 6. Fill Vercel credentials (API token) -7. Launch initial scan -8. Verify redirect to Scans page -9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") +7. Confirm provider connection without launching a scan +8. Verify return to Providers page +9. Verify provider exists in Providers table ### Expected Result - Vercel provider successfully added with API Token credentials -- Initial scan launched successfully -- User redirected to Scans page -- Scheduled scan appears in Scans table with correct provider and scan name +- Provider connection validated without launching a scan +- User returned to Providers page +- Provider appears in Providers table with the expected UID ### Key verification points @@ -1060,10 +1060,10 @@ - Provider details form accepts team ID and alias - Credentials page loads with the API Token field - API Token is properly filled in the correct (masked) field -- Launch scan page appears -- Successful redirect to Scans page after scan launch -- Provider exists in Scans table (verified by team ID) -- Scan name field contains "scheduled scan" +- Launch step appears +- Successful return to Providers page after closing the launch step +- Provider exists in Providers table (verified by team ID) +- Provider UID matches the expected value ### Notes diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index b5c9e53df9..4dc8f010ed 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -37,7 +37,6 @@ import { VercelProviderCredential, VERCEL_CREDENTIAL_OPTIONS, } from "./providers-page"; -import { ScansPage } from "../scans/scans-page"; import fs from "fs"; import { deleteProviderIfExists } from "../helpers"; @@ -45,7 +44,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add AWS Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const accountId = process.env.E2E_AWS_PROVIDER_ACCOUNT_ID ?? ""; const accessKey = process.env.E2E_AWS_PROVIDER_ACCESS_KEY ?? ""; @@ -123,16 +121,10 @@ test.describe("Add Provider", () => { await providersPage.fillStaticCredentials(staticCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to provider page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(accountId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + accountId, + ); }, ); @@ -196,16 +188,10 @@ test.describe("Add Provider", () => { await providersPage.fillRoleCredentials(roleCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to provider page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(accountId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + accountId, + ); }, ); @@ -271,16 +257,10 @@ test.describe("Add Provider", () => { await providersPage.fillRoleCredentials(roleCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to provider page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(accountId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + accountId, + ); }, ); @@ -297,6 +277,14 @@ test.describe("Add Provider", () => { ], }, async ({ page }) => { + // AWS Organizations (multi-account onboarding) is a Cloud-only feature, + // so this test must never run in the OSS CI. Gate explicitly on the + // Cloud env flag instead of relying on the org env vars being absent. + test.skip( + process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true", + "AWS Organizations multi-account onboarding is a Cloud-only feature", + ); + if (!organizationId || !organizationRoleArn) { test.skip( true, @@ -343,11 +331,10 @@ test.describe("Add Provider", () => { await providersPage.clickNext(); await providersPage.verifyOrganizationsLaunchStepLoaded(); - await providersPage.chooseOrganizationsScanSchedule("single"); + await providersPage.chooseOrganizationsScanSchedule("daily"); await providersPage.clickNext(); - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); + await providersPage.verifyLoadProviderPageAfterNewProvider(); }, ); }); @@ -355,7 +342,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add AZURE Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID ?? ""; @@ -422,16 +408,10 @@ test.describe("Add Provider", () => { await providersPage.fillAZURECredentials(azureCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(subscriptionId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + subscriptionId, + ); }, ); }); @@ -439,7 +419,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add M365 Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const domainId = process.env.E2E_M365_DOMAIN_ID ?? ""; @@ -519,16 +498,10 @@ test.describe("Add Provider", () => { await providersPage.fillM365Credentials(m365Credentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(domainId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + domainId, + ); }, ); @@ -596,16 +569,10 @@ test.describe("Add Provider", () => { await providersPage.fillM365CertificateCredentials(m365Credentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(domainId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + domainId, + ); }, ); }); @@ -613,7 +580,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add Kubernetes Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const context = process.env.E2E_KUBERNETES_CONTEXT ?? ""; @@ -696,16 +662,10 @@ test.describe("Add Provider", () => { await providersPage.fillKubernetesCredentials(kubernetesCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to provider page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(context); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + context, + ); }, ); }); @@ -713,7 +673,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add GCP Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const projectId = process.env.E2E_GCP_PROJECT_ID ?? ""; @@ -802,16 +761,10 @@ test.describe("Add Provider", () => { await providersPage.fillGCPServiceAccountKeyCredentials(gcpCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(projectId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + projectId, + ); }, ); }); @@ -819,7 +772,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add GitHub Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; test.describe("Add GitHub provider with username", () => { const username = process.env.E2E_GITHUB_USERNAME ?? ""; @@ -899,16 +851,10 @@ test.describe("Add Provider", () => { ); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(username); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + username, + ); }, ); test( @@ -981,16 +927,10 @@ test.describe("Add Provider", () => { await providersPage.fillGitHubAppCredentials(githubCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(username); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + username, + ); }, ); }); @@ -1071,16 +1011,10 @@ test.describe("Add Provider", () => { ); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(organization); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + organization, + ); }, ); }); @@ -1089,7 +1023,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add OCI Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const tenancyId = process.env.E2E_OCI_TENANCY_ID ?? ""; @@ -1162,16 +1095,10 @@ test.describe("Add Provider", () => { await providersPage.fillOCICredentials(ociCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(tenancyId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + tenancyId, + ); }, ); }); @@ -1179,7 +1106,6 @@ test.describe("Add Provider", () => { test.describe.serial("Add AlibabaCloud Provider", () => { // Providers page object let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const accountId = process.env.E2E_ALIBABACLOUD_ACCOUNT_ID ?? ""; @@ -1265,16 +1191,10 @@ test.describe("Add Provider", () => { ); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(accountId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + accountId, + ); }, ); @@ -1344,23 +1264,16 @@ test.describe("Add Provider", () => { await providersPage.fillAlibabaCloudRoleCredentials(roleCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(accountId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + accountId, + ); }, ); }); test.describe.serial("Add Google Workspace Provider", () => { let providersPage: ProvidersPage; - let scansPage: ScansPage; const customerId = process.env.E2E_GOOGLEWORKSPACE_CUSTOMER_ID ?? ""; const serviceAccountJson = @@ -1428,23 +1341,16 @@ test.describe("Add Provider", () => { ); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(customerId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + customerId, + ); }, ); }); test.describe.serial("Add Vercel Provider", () => { let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables const teamId = process.env.E2E_VERCEL_TEAM_ID ?? ""; @@ -1507,27 +1413,20 @@ test.describe("Add Provider", () => { await providersPage.fillVercelCredentials(vercelCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(teamId); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + teamId, + ); }, ); }); test.describe.serial("Add Okta Provider", () => { let providersPage: ProvidersPage; - let scansPage: ScansPage; // Test data from environment variables // Org Domain is lowercased by the form, so normalize here to match the - // stored provider UID used for cleanup and scan verification. + // stored provider UID used for cleanup. const orgDomain = (process.env.E2E_OKTA_DOMAIN ?? "").toLowerCase(); const clientId = process.env.E2E_OKTA_CLIENT_ID ?? ""; const privateKeyB64 = process.env.E2E_OKTA_BASE64_PRIVATE_KEY ?? ""; @@ -1599,16 +1498,10 @@ test.describe("Add Provider", () => { await providersPage.fillOktaCredentials(oktaCredentials); await providersPage.clickNext(); - // Launch scan - await providersPage.verifyLaunchScanPageLoaded(); - await providersPage.clickNext(); - - // Wait for redirect to scan page - scansPage = new ScansPage(page); - await scansPage.verifyPageLoaded(); - - // Verify scan status is "Scheduled scan" - await scansPage.verifyScheduledScanStatus(orgDomain); + // Confirm the provider connection without launching a scan + await providersPage.completeProviderConnectionWithoutLaunchingScan( + orgDomain, + ); }, ); }); diff --git a/ui/tests/scans/scans.md b/ui/tests/scans/scans.md index 2dcd329be6..5baef3a42d 100644 --- a/ui/tests/scans/scans.md +++ b/ui/tests/scans/scans.md @@ -21,6 +21,7 @@ - Admin user authentication required (admin.auth.setup setup) - Environment variables configured for : E2E_AWS_PROVIDER_ACCOUNT_ID,E2E_AWS_PROVIDER_ACCESS_KEY and E2E_AWS_PROVIDER_SECRET_KEY - Remove any existing AWS provider with the same Account ID before starting the test +- Create a connected AWS provider without launching a preparatory scan - This test must be run serially and never in parallel with other tests, as it requires the Account ID Provider to be already registered. ### Flow Steps @@ -55,5 +56,5 @@ - The scans view is tabbed (In Progress / Completed / Scheduled) and `/scans` defaults to Completed. Each empty tab renders an empty-state card instead of a table, so page-loaded checks assert the tabs shell, not the table. - The table may take a short time to reflect the new scan; assertions look for a row containing the account ID. -- Provider cleanup performed before each test to ensure clean state +- Provider cleanup and provider connection setup are performed before each test to ensure clean state - Tests should run serially to avoid state conflicts. diff --git a/ui/types/index.ts b/ui/types/index.ts index 69eb4032ac..7f7a035b47 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -10,5 +10,6 @@ export * from "./providers"; export * from "./providers-table"; export * from "./resources"; export * from "./scans"; +export * from "./schedules"; export * from "./tasks"; export * from "./tree"; diff --git a/ui/types/providers-table.ts b/ui/types/providers-table.ts index 553355b75b..21bfb1ab8c 100644 --- a/ui/types/providers-table.ts +++ b/ui/types/providers-table.ts @@ -5,6 +5,7 @@ import { OrganizationUnitResource, } from "./organizations"; import { ProviderProps } from "./providers"; +import { ScanScheduleSummary } from "./scans"; export const PROVIDERS_ROW_TYPE = { ORGANIZATION: "organization", @@ -52,6 +53,8 @@ export interface ProvidersProviderRow relationships: ProviderTableRelationships; groupNames: string[]; hasSchedule: boolean; + /** Cadence/next-run summary when the provider has a configured schedule. */ + scheduleSummary?: ScanScheduleSummary; subRows?: ProvidersTableRow[]; } diff --git a/ui/types/scans.ts b/ui/types/scans.ts index 1ebb40c408..14b1c45a41 100644 --- a/ui/types/scans.ts +++ b/ui/types/scans.ts @@ -83,12 +83,25 @@ export interface ScanProviderInfo { connected: boolean; } +/** Cadence summary computed from `/schedules`, e.g. "Weekly on Monday @ 9:00am". */ +export interface ScanScheduleSummary { + summary: string; + /** e.g. "Weekly on Monday" */ + cadence?: string; + nextScanAt?: string | null; + lastScanAt?: string | null; +} + export interface ScanProps { type: "scans"; id: string; attributes: ScanAttributes; relationships: ScanRelationships; providerInfo?: ScanResultProviderInfo; + /** Only on synthetic Scheduled-tab rows for schedules that have not fired yet. */ + pendingSchedule?: ScanScheduleSummary; + /** Current schedule cadence of the scan's provider, when configured. */ + providerSchedule?: ScanScheduleSummary; } export interface ScanEntityProviderInfo { diff --git a/ui/types/schedules.ts b/ui/types/schedules.ts new file mode 100644 index 0000000000..5ac820eaca --- /dev/null +++ b/ui/types/schedules.ts @@ -0,0 +1,97 @@ +import type { ProviderProps } from "./providers"; + +export const SCHEDULE_FREQUENCY = { + DAILY: "DAILY", + INTERVAL: "INTERVAL", + WEEKLY: "WEEKLY", + MONTHLY: "MONTHLY", +} as const; + +export type ScheduleFrequency = + (typeof SCHEDULE_FREQUENCY)[keyof typeof SCHEDULE_FREQUENCY]; + +/** Weekday names indexed by cron convention (0=Sunday). */ +export const SCHEDULE_WEEKDAY_LABELS = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +/** + * Scan-schedule capability modes. In Prowler OSS this is resolved purely from + * 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). + * - `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. + */ +export const SCAN_SCHEDULE_CAPABILITY = { + ADVANCED: "ADVANCED", + DAILY_LEGACY: "DAILY_LEGACY", + MANUAL_ONLY: "MANUAL_ONLY", +} as const; + +export type ScanScheduleCapability = + (typeof SCAN_SCHEDULE_CAPABILITY)[keyof typeof SCAN_SCHEDULE_CAPABILITY]; + +export interface ScheduleAttributes { + scan_enabled: boolean; + scan_frequency: ScheduleFrequency; + scan_hour: number | null; + scan_timezone: string; + scan_interval_hours: number | null; + scan_day_of_week: number | null; + scan_day_of_month: number | null; + /** Read-only, server-computed next fire time; null when paused/unconfigured. */ + next_scan_at?: string | null; + /** Read-only, completed_at of the provider's last completed scan. */ + last_scan_at?: string | null; +} + +export interface ScheduleRelationships { + provider: { + data: { + type: "providers"; + id: string; + }; + }; +} + +export interface ScheduleProps { + type: "schedules"; + id: string; + attributes: ScheduleAttributes; + relationships: ScheduleRelationships; +} + +export interface ScheduleApiResponse { + data: ScheduleProps; + included?: ProviderProps[]; +} + +export interface ScheduleUpdatePayload { + scan_enabled: boolean; + scan_frequency: ScheduleFrequency; + scan_hour: number; + scan_timezone: string; + scan_interval_hours: number | null; + scan_day_of_week: number | null; + scan_day_of_month: number | null; +} + +export interface ScheduleFormValues { + frequency: ScheduleFrequency; + hour: number; + dayOfWeek: number; + dayOfMonth: number; + intervalHours: number; + launchInitialScan: boolean; +}