mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): per-provider scan schedule management gated by capability (#11521)
This commit is contained in:
@@ -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) =>
|
||||
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
@@ -167,6 +171,29 @@ describe("getResourceEvents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a generic error when a gateway returns HTML", async () => {
|
||||
// Given
|
||||
const mockResponse = new Response(
|
||||
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
|
||||
{
|
||||
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"));
|
||||
|
||||
@@ -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;
|
||||
return {
|
||||
error:
|
||||
const errorMessage =
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
rawText ||
|
||||
response.statusText ||
|
||||
defaultError,
|
||||
fallbackError;
|
||||
return {
|
||||
error: sanitizeErrorMessage(String(errorMessage), fallbackError),
|
||||
status: response.status,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: rawText || response.statusText || defaultError,
|
||||
error: sanitizeErrorMessage(rawText || fallbackError, fallbackError),
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
|
||||
{
|
||||
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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+41
-12
@@ -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`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
const body = {
|
||||
data: {
|
||||
type: "daily-schedules",
|
||||
attributes: {
|
||||
provider_id: providerId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
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<string> {
|
||||
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.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./schedules";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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<ScheduleAttributes> = {},
|
||||
): 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,13 +134,32 @@ const buildScheduledProviderIds = (scans: ScanProps[]): Set<string> => {
|
||||
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<string>,
|
||||
providersResponse: ProvidersApiResponse | undefined,
|
||||
scanScheduledProviderIds: Set<string>,
|
||||
schedulesByProviderId: Record<string, ScheduleAttributes>,
|
||||
): ProvidersProviderRow[] => {
|
||||
const providerGroupLookup = createProviderGroupLookup(providersResponse);
|
||||
const now = new Date();
|
||||
|
||||
return (providersResponse?.data ?? []).map((provider) => ({
|
||||
return (providersResponse?.data ?? []).map((provider) => {
|
||||
const scheduleSummary = buildProviderScheduleSummaryFor(
|
||||
schedulesByProviderId[provider.id],
|
||||
now,
|
||||
);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
groupNames:
|
||||
@@ -141,8 +167,13 @@ const enrichProviders = (
|
||||
(providerGroup: { id: string }) =>
|
||||
providerGroupLookup.get(providerGroup.id) ?? "Unknown Group",
|
||||
) ?? [],
|
||||
hasSchedule: scheduledProviderIds?.has(provider.id) ?? false,
|
||||
}));
|
||||
// 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,
|
||||
|
||||
@@ -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<PendingRowProviderFilterParam>;
|
||||
|
||||
const PROVIDER_TYPE_FILTER_KEYS = [
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
|
||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
||||
|
||||
const getFilterSearchQuery = (
|
||||
filters: Record<string, string | string[]>,
|
||||
@@ -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<PendingRowProviderFilterParam>,
|
||||
): 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<number> => {
|
||||
@@ -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<string, string | string[]>;
|
||||
}): Promise<Set<string>> => {
|
||||
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({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
<SSRDataTableScans
|
||||
searchParams={resolvedSearchParams}
|
||||
providers={providers}
|
||||
/>
|
||||
</Suspense>
|
||||
</ScansPageShell>
|
||||
)}
|
||||
@@ -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 (
|
||||
<ScanJobsTable
|
||||
data={expandedScansData}
|
||||
meta={meta}
|
||||
data={tableData}
|
||||
meta={tableMeta}
|
||||
tab={tab}
|
||||
hasFilters={hasUserFilters}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<StackedCell
|
||||
primary={schedule.cadence ?? schedule.summary}
|
||||
secondary={formatLocalTimeWithZone(schedule.nextScanAt ?? null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{hasSchedule ? "Daily" : "None"}
|
||||
</span>
|
||||
<Button asChild variant="link" size="sm" className="text-xs">
|
||||
<Link href={`/scans?filter[provider_uid]=${providerUid}`}>
|
||||
View Jobs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<OrgLaunchScan
|
||||
onClose={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
isScanLimitReached
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-8">
|
||||
@@ -146,6 +177,20 @@ export function OrgLaunchScan({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isManualOnly ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for trial accounts. These
|
||||
accounts will run a one-time manual scan now.
|
||||
</p>
|
||||
{isScanLimitReached && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Select a Prowler scan schedule for these accounts.
|
||||
@@ -170,6 +215,7 @@ export function OrgLaunchScan({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-system-success flex items-center gap-2 text-sm whitespace-nowrap">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
<Badge variant="success" className="text-sm">
|
||||
Connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (connected === false) {
|
||||
return (
|
||||
<div className="text-text-error-primary flex items-center gap-2 text-sm whitespace-nowrap">
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
<span>Connection failed</span>
|
||||
</div>
|
||||
<Badge variant="error" className="text-sm">
|
||||
Connection failed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-text-neutral-secondary flex items-center gap-2 text-sm whitespace-nowrap">
|
||||
<ShieldOff className="size-4 shrink-0" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
<Badge variant="tag" className="text-text-neutral-secondary text-sm">
|
||||
Not connected
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -259,7 +251,7 @@ export function getColumnProviders(
|
||||
return (
|
||||
<LinkToScans
|
||||
hasSchedule={row.original.hasSchedule}
|
||||
providerUid={row.original.attributes.uid}
|
||||
schedule={row.original.scheduleSummary}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 ? (
|
||||
<div role="dialog" aria-label="Edit Scan Schedule">
|
||||
Editing schedule for {provider?.providerId}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
@@ -143,6 +177,23 @@ const createOuRow = () =>
|
||||
}) as unknown as Row<ProvidersTableRow>;
|
||||
|
||||
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(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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<EditScanScheduleState>({
|
||||
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({
|
||||
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
|
||||
)}
|
||||
</Modal>
|
||||
<EditScanScheduleModal
|
||||
open={isScheduleOpen}
|
||||
onOpenChange={setIsScheduleOpen}
|
||||
provider={scheduleProvider}
|
||||
state={scheduleState}
|
||||
/>
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
@@ -371,6 +457,27 @@ export function DataTableRowActions({
|
||||
label="Edit Provider Alias"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Timer />}
|
||||
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 && (
|
||||
<ActionDropdownItem
|
||||
icon={<CalendarClock />}
|
||||
label="Edit Scan Schedule"
|
||||
onSelect={() => void openScheduleEditor()}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
|
||||
@@ -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,7 +114,13 @@ export function ProviderWizardModal({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
|
||||
{/* Anchors the add-provider tour's final step to the wizard content and
|
||||
footer, keeping the real form controls clickable under the overlay. */}
|
||||
<div
|
||||
data-tour-id="add-provider-wizard-body"
|
||||
className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
|
||||
{isProviderFlow ? (
|
||||
<WizardStepper
|
||||
@@ -128,16 +141,12 @@ export function ProviderWizardModal({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
data-tour-id="add-provider-wizard-body"
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
aria-hidden
|
||||
className="hidden w-[100px] min-w-0 shrink lg:block"
|
||||
/>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -167,12 +176,15 @@ export function ProviderWizardModal({
|
||||
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
|
||||
<CredentialsStep
|
||||
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
|
||||
onBack={() =>
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)
|
||||
}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
|
||||
{isProviderFlow &&
|
||||
currentStep === PROVIDER_WIZARD_STEP.TEST && (
|
||||
<TestConnectionStep
|
||||
onSuccess={handleTestSuccess}
|
||||
onResetCredentials={() =>
|
||||
@@ -188,12 +200,17 @@ export function ProviderWizardModal({
|
||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
||||
onClose={handleClose}
|
||||
onFooterChange={setFooterConfig}
|
||||
capability={scanScheduleCapability}
|
||||
isScanLimitReached={isScanLimitReached}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
|
||||
<OrgSetupForm
|
||||
onBack={isOrgDirectEntry ? handleClose : backToProviderFlow}
|
||||
onBack={
|
||||
isOrgDirectEntry ? handleClose : backToProviderFlow
|
||||
}
|
||||
onClose={handleClose}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
@@ -230,13 +247,16 @@ export function ProviderWizardModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
|
||||
<OrgLaunchScan
|
||||
onClose={handleClose}
|
||||
onBack={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
capability={scanScheduleCapability}
|
||||
isScanLimitReached={isScanLimitReached}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -255,13 +275,13 @@ export function ProviderWizardModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(resolvedFooterConfig.showBack ||
|
||||
resolvedFooterConfig.showSecondaryAction ||
|
||||
resolvedFooterConfig.showAction) && (
|
||||
<div className="mt-8 w-full pt-6 lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]">
|
||||
<div className="mt-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{resolvedFooterConfig.showBack && (
|
||||
@@ -335,7 +355,6 @@ export function ProviderWizardModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
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,28 +38,74 @@ vi.mock("@/components/ui", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LaunchStep", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
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();
|
||||
const seedConnectedProvider = () => {
|
||||
useProviderWizardStore.setState({
|
||||
providerId: "provider-1",
|
||||
providerType: "gcp",
|
||||
providerUid: "project-123",
|
||||
mode: "add",
|
||||
});
|
||||
};
|
||||
|
||||
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
const lastFooterConfig = (onFooterChange: ReturnType<typeof vi.fn>) =>
|
||||
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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("Prowler OSS (non-Cloud)", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
it("defaults to run now and locks schedule mode outside Cloud", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
seedConnectedProvider();
|
||||
|
||||
render(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
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(
|
||||
<LaunchStep
|
||||
@@ -57,34 +114,257 @@ describe("LaunchStep", () => {
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
|
||||
// When
|
||||
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
|
||||
await act(async () => {
|
||||
initialFooterConfig.onAction?.();
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
describe("Prowler Cloud subscribed", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
updateScheduleMock.mockResolvedValue({ data: { id: "provider-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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={onClose}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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({
|
||||
title: "Scan Launched",
|
||||
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}`,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={onClose}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={onClose}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
isScanLimitReached
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScanScheduleOption>(
|
||||
SCAN_SCHEDULE.DAILY,
|
||||
const [mode, setMode] = useState<LaunchMode>(
|
||||
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
||||
);
|
||||
const launchActionRef = useRef<() => void>(() => {});
|
||||
const form = useForm<ScheduleFormValues>({
|
||||
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: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
|
||||
@@ -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 = (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href={`/scans?tab=${targetTab}`}>Go to scans</Link>
|
||||
</ToastAction>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
<p className="text-sm font-medium">
|
||||
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -121,13 +252,21 @@ export function LaunchStep({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
{(providerId || providerUid) && (
|
||||
<EntityInfo
|
||||
cloudProvider={providerType ?? undefined}
|
||||
entityAlias={providerAlias ?? providerUid ?? providerId ?? undefined}
|
||||
entityId={providerUid ?? providerId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
|
||||
<h3 className="text-sm font-semibold">Connection validated!</h3>
|
||||
<h3 className="text-sm font-semibold">Account Connected!</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Choose how you want to launch scans for this provider.
|
||||
Your account is connected to Prowler and ready to Scan!
|
||||
</p>
|
||||
|
||||
{!providerId && (
|
||||
@@ -136,28 +275,57 @@ export function LaunchStep({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
|
||||
<Select
|
||||
value={scheduleOption}
|
||||
onValueChange={(value) =>
|
||||
setScheduleOption(value as ScanScheduleOption)
|
||||
}
|
||||
disabled={isLaunching || !providerId}
|
||||
<Field>
|
||||
<FieldLabel>Mode</FieldLabel>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as LaunchMode)}
|
||||
className="flex flex-row flex-wrap gap-6"
|
||||
aria-label="Scan mode"
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-[376px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SCAN_SCHEDULE.DAILY}>
|
||||
Scan Daily (every 24 hours)
|
||||
</SelectItem>
|
||||
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
|
||||
Run a single scan (no recurring schedule)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
|
||||
Run now
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem
|
||||
value={LAUNCH_MODE.SCHEDULE}
|
||||
aria-label="On a schedule"
|
||||
disabled={!isAdvanced}
|
||||
/>
|
||||
On a schedule
|
||||
{!isAdvanced &&
|
||||
(isManualOnly ? (
|
||||
<CloudFeatureBadge label="Requires subscription" size="sm" />
|
||||
) : (
|
||||
<CloudFeatureBadgeLink size="sm" />
|
||||
))}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{!isAdvanced && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for this account. Run now to get
|
||||
immediate findings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLimitBlocked && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isScheduleMode && (
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
disabled={isLaunching || !providerId}
|
||||
showLaunchInitialScan
|
||||
showNextScheduledCopy
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,36 @@ 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(() => ({
|
||||
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: () => ({
|
||||
refresh: refreshMock,
|
||||
@@ -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(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[provider]}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[provider]}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
isScanLimitReached
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /launch scan/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof launchScanSchema>;
|
||||
|
||||
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<LaunchScanFormValues>({
|
||||
resolver: zodResolver(launchScanSchema),
|
||||
defaultValues: { providerId: "", scanAlias: "" },
|
||||
});
|
||||
const scheduleForm = useForm<ScheduleFormValues>({
|
||||
resolver: zodResolver(scheduleFormSchema),
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
const [mode, setMode] = useState<LaunchMode>(LAUNCH_MODE.NOW);
|
||||
const [scheduleLoad, setScheduleLoad] = useState<ScheduleLoadState>(
|
||||
SCHEDULE_LOAD_STATE.IDLE,
|
||||
);
|
||||
// Guards against out-of-order responses when switching providers quickly.
|
||||
const requestedProviderRef = useRef<string>("");
|
||||
|
||||
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: (
|
||||
<ToastAction altText="View scheduled scans" asChild>
|
||||
<Link href={`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`}>
|
||||
View schedule
|
||||
</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
onClose();
|
||||
router.refresh();
|
||||
})();
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
// min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
|
||||
<form onSubmit={onSubmit} className="flex min-w-0 flex-col gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudCog className="text-text-neutral-secondary size-4" />
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
@@ -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,6 +273,40 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
|
||||
{providerError && <FieldError>{providerError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
{!isManualOnly && (
|
||||
<Field>
|
||||
<FieldLabel>Mode</FieldLabel>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={handleModeChange}
|
||||
className="flex flex-row flex-wrap gap-6"
|
||||
aria-label="Scan mode"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
|
||||
Run now
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem
|
||||
value={LAUNCH_MODE.SCHEDULE}
|
||||
aria-label="On a schedule"
|
||||
disabled={!isAdvanced}
|
||||
/>
|
||||
On a schedule
|
||||
{!isAdvanced && <CloudFeatureBadgeLink size="sm" />}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isLimitBlocked && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isScheduleMode && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="launch-scan-alias">Alias (optional)</FieldLabel>
|
||||
<Input
|
||||
@@ -127,14 +316,50 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
|
||||
/>
|
||||
{aliasError && <FieldError>{aliasError}</FieldError>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isScheduleMode && isScheduleLoading && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="text-sm">Loading scan schedule...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduleMode && scheduleLoad === SCHEDULE_LOAD_STATE.ERROR && (
|
||||
<FieldError>
|
||||
Failed to load the current scan schedule. Saving will overwrite it.
|
||||
</FieldError>
|
||||
)}
|
||||
|
||||
{isScheduleMode && !isScheduleLoading && (
|
||||
<ScanScheduleFields
|
||||
form={scheduleForm}
|
||||
disabled={isSubmitting || !providerId}
|
||||
showLaunchInitialScan
|
||||
showNextScheduledCopy
|
||||
/>
|
||||
)}
|
||||
|
||||
{rootError && <FieldError>{rootError}</FieldError>}
|
||||
|
||||
<FormButtons
|
||||
onCancel={onClose}
|
||||
submitText={isSubmitting ? "Launching..." : "Launch Scan"}
|
||||
loadingText="Launching..."
|
||||
isDisabled={isSubmitting || !providers.length}
|
||||
submitText={
|
||||
isSubmitting
|
||||
? isScheduleMode
|
||||
? "Saving..."
|
||||
: "Launching..."
|
||||
: isScheduleMode
|
||||
? "Save Schedule"
|
||||
: "Launch Scan"
|
||||
}
|
||||
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
|
||||
isDisabled={
|
||||
isSubmitting ||
|
||||
!providers.length ||
|
||||
isScheduleLoading ||
|
||||
isLimitBlocked
|
||||
}
|
||||
rightIcon={<Rocket className="size-4" />}
|
||||
/>
|
||||
</form>
|
||||
@@ -145,7 +370,11 @@ export function LaunchScanModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
providers,
|
||||
capability,
|
||||
isScanLimitReached = false,
|
||||
}: LaunchScanModalProps) {
|
||||
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -157,6 +386,8 @@ export function LaunchScanModal({
|
||||
<LaunchScanForm
|
||||
providers={providers}
|
||||
onClose={() => onOpenChange(false)}
|
||||
capability={resolvedCapability}
|
||||
isScanLimitReached={isScanLimitReached}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, ScheduleAttributes>;
|
||||
/** Providers that already have a real `state=scheduled` Scan row. */
|
||||
coveredProviderIds: Set<string>;
|
||||
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<string> {
|
||||
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<string, unknown>,
|
||||
keys: string[],
|
||||
|
||||
@@ -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 ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : 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(
|
||||
<EditScanScheduleModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
provider={provider}
|
||||
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EditScanScheduleModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
provider={provider}
|
||||
state={{
|
||||
kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
|
||||
schedule: {
|
||||
...schedule,
|
||||
attributes: { ...schedule.attributes, scan_hour: null },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ScheduleFormValues>({
|
||||
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 (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<EntityInfo
|
||||
cloudProvider={provider.providerType}
|
||||
entityAlias={provider.providerAlias ?? provider.providerUid}
|
||||
entityId={provider.providerUid}
|
||||
/>
|
||||
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
disabled={isSubmitting || isRemoving}
|
||||
headerAction={
|
||||
hasSchedule ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsConfirmRemoveOpen(true)}
|
||||
disabled={isSubmitting || isRemoving}
|
||||
className="text-text-error-primary"
|
||||
>
|
||||
<CircleX className="size-4" />
|
||||
Remove Scan Schedule
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{rootError && <FieldError>{rootError}</FieldError>}
|
||||
|
||||
<FormButtons
|
||||
onCancel={onClose}
|
||||
submitText={isSubmitting ? "Saving..." : "Save"}
|
||||
loadingText="Saving..."
|
||||
isDisabled={isSubmitting || isRemoving}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isConfirmRemoveOpen}
|
||||
onOpenChange={setIsConfirmRemoveOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
|
||||
>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setIsConfirmRemoveOpen(false)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<CircleX className="size-4" />
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditScanScheduleModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
state,
|
||||
}: EditScanScheduleModalProps) {
|
||||
const close = () => onOpenChange(false);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Scan Schedule"
|
||||
size="2xl"
|
||||
className="gap-8"
|
||||
>
|
||||
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADING && (
|
||||
<div className="flex min-h-[240px] items-center justify-center gap-3">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="text-sm">Loading scan schedule...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === EDIT_SCAN_SCHEDULE_STATE.ERROR && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<FieldError>{state.message}</FieldError>
|
||||
<Button type="button" variant="outline" onClick={close}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && provider && (
|
||||
<EditScanScheduleForm
|
||||
key={`${provider.providerId}-${state.schedule?.attributes.scan_hour ?? "none"}`}
|
||||
provider={provider}
|
||||
schedule={state.schedule}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SaveScheduleResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<ScheduleFormValues>({
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
showNextScheduledCopy
|
||||
canUseAdvancedSchedule
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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(<ScheduleFieldsHarness />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ScheduleFormValues>;
|
||||
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 (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{labelAddon}
|
||||
</div>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(nextValue) => onChange(Number(nextValue))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{values.map((option) => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<CloudFeatureBadgeLink size="sm" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="text-text-neutral-primary size-5" />
|
||||
<h3 className="text-text-neutral-primary text-sm font-medium">
|
||||
Scan Schedule
|
||||
</h3>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="hour"
|
||||
render={({ field }) => (
|
||||
<NumberSelect
|
||||
label="Scan Time"
|
||||
labelAddon={cloudUpgradeBadge}
|
||||
value={field.value}
|
||||
values={HOUR_OPTIONS}
|
||||
onChange={field.onChange}
|
||||
disabled={advancedDisabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Repeats</FieldLabel>
|
||||
{cloudUpgradeBadge}
|
||||
</div>
|
||||
<Select
|
||||
value={
|
||||
canUseAdvancedSchedule
|
||||
? field.value
|
||||
: SCHEDULE_FREQUENCY.DAILY
|
||||
}
|
||||
onValueChange={field.onChange}
|
||||
disabled={advancedDisabled}
|
||||
>
|
||||
<SelectTrigger aria-label="Repeats">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FREQUENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{frequencyLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === SCHEDULE_FREQUENCY.WEEKLY && (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="dayOfWeek"
|
||||
render={({ field }) => (
|
||||
<NumberSelect
|
||||
label="Day of week"
|
||||
value={field.value}
|
||||
values={WEEKDAY_OPTIONS}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === SCHEDULE_FREQUENCY.MONTHLY && (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="dayOfMonth"
|
||||
render={({ field }) => (
|
||||
<NumberSelect
|
||||
label="Day of month"
|
||||
value={field.value}
|
||||
values={MONTH_DAY_OPTIONS.map((day) => ({
|
||||
value: day,
|
||||
label: String(day),
|
||||
}))}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNextScheduledCopy &&
|
||||
(canUseAdvancedSchedule ? (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{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}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
A daily scan will run automatically once the account is connected.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{showLaunchInitialScan && (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="launchInitialScan"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-3 text-sm font-medium">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
disabled={disabled}
|
||||
aria-label="Launch an initial scan now for immediate findings"
|
||||
/>
|
||||
<span>Launch an initial scan now for immediate findings</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="tag">Pending</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This scan has not been created yet. Its details will appear after the
|
||||
first scheduled run.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[240px] min-w-0">
|
||||
<EntityInfo
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { getScanScheduleLabel } from "@/components/scans/scans.utils";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { StackedCell } from "@/components/shadcn";
|
||||
import { formatLocalTimeWithZone } from "@/lib/date-utils";
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
// Trigger label on top, cadence (in the browser's timezone) underneath.
|
||||
export function ScheduleCell({ scan }: { scan: ScanProps }) {
|
||||
const schedule = scan.providerSchedule;
|
||||
const localTime = formatLocalTimeWithZone(schedule?.nextScanAt ?? null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{getScanScheduleLabel(scan.attributes.trigger)}
|
||||
</span>
|
||||
{scan.attributes.scheduled_at && (
|
||||
<DateWithTime dateTime={scan.attributes.scheduled_at} showTime />
|
||||
)}
|
||||
</div>
|
||||
<StackedCell
|
||||
primary={getScanScheduleLabel(scan.attributes.trigger)}
|
||||
secondary={
|
||||
schedule
|
||||
? `${schedule.cadence ?? schedule.summary}${localTime ? ` @ ${localTime}` : ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
Progress: () => <div />,
|
||||
StackedCell: ({
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
primary: ReactNode;
|
||||
secondary?: ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{primary}</span>
|
||||
{secondary ? <span>{secondary}</span> : null}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
@@ -148,6 +159,8 @@ describe("getScanJobsColumns", () => {
|
||||
"account",
|
||||
"scanInfo",
|
||||
"scanSchedule",
|
||||
"nextScan",
|
||||
"lastScan",
|
||||
"actions",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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<ScanProps> => ({
|
||||
cell: ({ row }) => <ScheduleCell scan={row.original} />,
|
||||
});
|
||||
|
||||
const getScheduleSummary = (scan: ScanProps) =>
|
||||
scan.pendingSchedule ?? scan.providerSchedule;
|
||||
|
||||
const renderDateCell = (value: string | null) => {
|
||||
const date = formatLocalDate(value);
|
||||
if (!date) return <span>-</span>;
|
||||
|
||||
return (
|
||||
<StackedCell primary={date} secondary={formatLocalTimeWithZone(value)} />
|
||||
);
|
||||
};
|
||||
|
||||
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.scheduled_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
|
||||
// Cadence on top, local fire time underneath.
|
||||
cell: ({ row }) => {
|
||||
const schedule = getScheduleSummary(row.original);
|
||||
if (!schedule) return <span>-</span>;
|
||||
|
||||
return (
|
||||
<StackedCell
|
||||
primary={schedule.cadence ?? schedule.summary}
|
||||
secondary={formatLocalTimeWithZone(
|
||||
row.original.attributes.scheduled_at,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const nextScanColumn: ColumnDef<ScanProps> = {
|
||||
id: "nextScan",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Next Scan" />
|
||||
),
|
||||
// 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<ScanProps> = {
|
||||
id: "lastScan",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Last Scan" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
renderDateCell(getScheduleSummary(row.original)?.lastScanAt ?? null),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
@@ -104,13 +149,10 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Launched" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime
|
||||
dateTime={
|
||||
cell: ({ row }) =>
|
||||
renderDateCell(
|
||||
row.original.attributes.started_at ||
|
||||
row.original.attributes.inserted_at
|
||||
}
|
||||
/>
|
||||
row.original.attributes.inserted_at,
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
@@ -141,9 +183,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
param="updated_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.completed_at} />
|
||||
),
|
||||
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
|
||||
},
|
||||
actionsColumn,
|
||||
];
|
||||
@@ -152,19 +192,8 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scheduledScanScheduleColumn,
|
||||
/*
|
||||
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
|
||||
* {
|
||||
* id: "lastScan",
|
||||
* header: ({ column }) => (
|
||||
* <DataTableColumnHeader column={column} title="Last Run" />
|
||||
* ),
|
||||
* cell: ({ row }) => (
|
||||
* <DateWithTime dateTime={row.original.attributes.completed_at} />
|
||||
* ),
|
||||
* enableSorting: false,
|
||||
* },
|
||||
*/
|
||||
nextScanColumn,
|
||||
lastScanColumn,
|
||||
actionsColumn,
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
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 ? (
|
||||
<div role="dialog" aria-label="Edit Scan Schedule">
|
||||
Editing schedule for {provider?.providerId}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const makeScan = (
|
||||
overrides: Partial<ScanProps["attributes"]> = {},
|
||||
): 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(<ScanJobsRowActions scan={makeScan()} />);
|
||||
|
||||
// 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(<ScanJobsRowActions scan={makeScan()} />);
|
||||
@@ -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(<ScanJobsRowActions scan={makeScan()} />);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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<EditScanScheduleState>({
|
||||
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
|
||||
});
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
|
||||
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 (
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown>
|
||||
{canEditSchedule && (
|
||||
<ActionDropdownItem
|
||||
icon={<CalendarClock />}
|
||||
label="Edit Scan Schedule"
|
||||
onSelect={() => void openScheduleEditor()}
|
||||
/>
|
||||
)}
|
||||
{/* Synthetic pending rows have no Scan to alias. */}
|
||||
{!scan.pendingSchedule && (
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Scan Alias"
|
||||
onSelect={() => setEditOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
@@ -126,12 +212,6 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
|
||||
onSelect={() => void openErrorDetails()}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */}
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit"
|
||||
onSelect={() => setEditOpen(true)}
|
||||
/>
|
||||
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
|
||||
</ActionDropdown>
|
||||
|
||||
@@ -147,6 +227,13 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
|
||||
onOpenChange={setErrorOpen}
|
||||
state={errorState}
|
||||
/>
|
||||
|
||||
<EditScanScheduleModal
|
||||
open={scheduleOpen}
|
||||
onOpenChange={setScheduleOpen}
|
||||
provider={scheduleProvider}
|
||||
state={scheduleState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
<CheckIcon
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { StackedCell } from "./stacked-cell";
|
||||
|
||||
describe("StackedCell", () => {
|
||||
it("renders primary and secondary lines", () => {
|
||||
render(<StackedCell primary="Jun 15, 2026" secondary="12:00AM MAD" />);
|
||||
|
||||
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(<StackedCell primary="Manual" />);
|
||||
|
||||
expect(screen.getByText("Manual")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("span")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="stacked-cell"
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
|
||||
{primary}
|
||||
</span>
|
||||
{secondary ? (
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
{secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +30,11 @@ 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}`
|
||||
const fullText =
|
||||
showTime && timeWithZone
|
||||
? `${formattedDate} ${timeWithZone}`
|
||||
: formattedDate;
|
||||
|
||||
const content = (
|
||||
@@ -59,14 +54,14 @@ export const DateWithTime = ({
|
||||
>
|
||||
{formattedDate}
|
||||
</span>
|
||||
{showTime && (
|
||||
{showTime && timeWithZone && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary text-xs font-medium whitespace-nowrap",
|
||||
inline && "truncate",
|
||||
)}
|
||||
>
|
||||
{formattedTime} {timezone}
|
||||
{timeWithZone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,14 +44,14 @@ export function DataTableExpandToggle<TData>({
|
||||
const isExpanded = isExpandedProp ?? row.getIsExpanded();
|
||||
|
||||
if (!row.getCanExpand()) {
|
||||
return <div className="w-4" />;
|
||||
return <div className="w-4 shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={row.getToggleExpandedHandler()}
|
||||
className={cn(
|
||||
"rounded transition-colors",
|
||||
"shrink-0 rounded transition-colors",
|
||||
"hover:bg-prowler-white/10",
|
||||
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
|
||||
@@ -73,12 +73,13 @@ export function DataTableExpandableCell<TData>({
|
||||
{canExpand ? (
|
||||
<DataTableExpandToggle row={row} isExpanded={isExpanded} />
|
||||
) : !isChildRow ? (
|
||||
<div className="w-4" />
|
||||
// shrink-0: keep this fixed-width spacer from collapsing under wide content, which would drag the checkbox left
|
||||
<div className="w-4 shrink-0" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{checkboxSlot && (
|
||||
<div className="mr-2 flex items-center">{checkboxSlot}</div>
|
||||
<div className="mr-2 flex shrink-0 items-center">{checkboxSlot}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,19 @@ const columns: ColumnDef<TestRow>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const columnsWithActions: ColumnDef<TestRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: () => <button type="button">Actions</button>,
|
||||
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(
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={[{ name: "Finding A" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -286,16 +298,21 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow key={`${headerGroup.id}-${selectionKey}-${expansionKey}`}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const size = header.getSize();
|
||||
const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getStickyActionColumnClassName(
|
||||
header.column.id,
|
||||
"header",
|
||||
)}
|
||||
style={
|
||||
getSubRows && size !== DEFAULT_COLUMN_SIZE
|
||||
? { width: `${size}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
{header.isPlaceholder || isActionsHeader
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
@@ -323,13 +340,19 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
{...getRowAttributes?.(row)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(onRowClick && "cursor-pointer")}
|
||||
className={cn("group", onRowClick && "cursor-pointer")}
|
||||
onClick={(event) =>
|
||||
handleRowClick(row, event.target as HTMLElement)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getStickyActionColumnClassName(
|
||||
cell.column.id,
|
||||
"cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
+18
-1
@@ -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(
|
||||
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
|
||||
);
|
||||
|
||||
// When
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
// Then
|
||||
expect(message).toBe(
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-3
@@ -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 =
|
||||
/(?:<!doctype\s+html|<html\b|<\/?(?:head|title|body|center|h1|h2|pre)\b)/i;
|
||||
|
||||
export const sanitizeErrorMessage = (
|
||||
message: string,
|
||||
fallback = GENERIC_SERVER_ERROR_MESSAGE,
|
||||
): string => {
|
||||
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.";
|
||||
}
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<ScheduleAttributes> = {},
|
||||
): 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> = {},
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, ScheduleAttributes> {
|
||||
const byProviderId: Record<string, ScheduleAttributes> = {};
|
||||
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<ScheduleAttributes, "scan_hour">,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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) =>
|
||||
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
|
||||
}));
|
||||
|
||||
import { handleApiResponse } from "./server-actions-helper";
|
||||
|
||||
describe("server action error handling", () => {
|
||||
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(
|
||||
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
|
||||
{
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
+10
-10
@@ -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 });
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
// 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<void> {
|
||||
|
||||
+164
-164
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user