diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 8b777cad10..ca6fd2d099 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -172,6 +172,7 @@ export const getUserByMe = async (accessToken: string) => { manage_scans: userRole.attributes.manage_scans || false, manage_integrations: userRole.attributes.manage_integrations || false, manage_billing: userRole.attributes.manage_billing || false, + manage_alerts: userRole.attributes.manage_alerts || false, unlimited_visibility: userRole.attributes.unlimited_visibility || false, }; diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts new file mode 100644 index 0000000000..1e5dfaf189 --- /dev/null +++ b/ui/actions/roles/roles.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { addRole, updateRole } from "./roles"; + +const lastRequestBody = () => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [, init] = call; + return JSON.parse(String((init as RequestInit).body)); +}; + +const makeRoleFormData = () => { + const formData = new FormData(); + formData.set("name", "Alert manager"); + formData.set("manage_users", "false"); + formData.set("manage_account", "false"); + formData.set("manage_billing", "false"); + formData.set("manage_providers", "false"); + formData.set("manage_integrations", "false"); + formData.set("manage_scans", "false"); + formData.set("manage_alerts", "true"); + formData.set("unlimited_visibility", "false"); + return formData; +}; + +describe("role actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: { id: "role-1" } }); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error" }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { id: "role-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("includes manage_alerts when creating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); + }); + + it("omits manage_alerts when creating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_alerts", + ); + }); + + it("includes manage_alerts when updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); + }); +}); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 24895e4369..645db72951 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -107,10 +107,12 @@ export const addRole = async (formData: FormData) => { }, }; - // Conditionally include manage_billing for cloud environment + // Conditionally include Prowler Cloud permissions. if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; + payload.data.attributes.manage_alerts = + formData.get("manage_alerts") === "true"; } // Add provider groups relationships only if there are items @@ -162,10 +164,12 @@ export const updateRole = async (formData: FormData, roleId: string) => { }, }; - // Conditionally include manage_billing for cloud environments + // Conditionally include Prowler Cloud permissions. if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; + payload.data.attributes.manage_alerts = + formData.get("manage_alerts") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts new file mode 100644 index 0000000000..d95966e680 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { confirmAlertRecipient } from "./confirm-alert-recipient"; + +const fetchMock = vi.fn(); + +const lastFetchCall = (): { url: string; init: RequestInit } => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [url, init] = call; + return { url: String(url), init: (init ?? {}) as RequestInit }; +}; + +describe("confirmAlertRecipient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("calls the public confirmation endpoint without auth headers", async () => { + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: true, + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "https://api.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + expect(init).toEqual({ + headers: { Accept: "application/json" }, + cache: "no-store", + }); + }); + + it("returns the API message for invalid tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "invalid_token", + message: "This link is invalid or has expired.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await confirmAlertRecipient("expired-token"); + + // Then + expect(result).toEqual({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + }); + + it("returns the API message for missing tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "missing_token", + message: "This link is missing a token.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await confirmAlertRecipient(); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_token", + message: "This link is missing a token.", + }); + expect(lastFetchCall().url).toBe( + "https://api.example.com/api/v1/alerts/recipients/confirm", + ); + }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this confirmation link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns the fallback message when the request fails", async () => { + // Given + fetchMock.mockRejectedValueOnce(new Error("network down")); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "network_error", + message: + "We could not process this confirmation link. Please try again later.", + }); + }); +}); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts new file mode 100644 index 0000000000..b567bb4124 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -0,0 +1,79 @@ +interface AlertConfirmApiResponse { + state?: string; + message?: string; +} + +interface AlertConfirmResult { + ok: boolean; + state: string; + message: string; +} + +const FALLBACK_CONFIRM_ERROR = + "We could not process this confirmation link. Please try again later."; + +const toMessage = (payload: unknown): string | null => { + if ( + typeof payload === "object" && + payload !== null && + "message" in payload && + typeof payload.message === "string" + ) { + return payload.message; + } + + return null; +}; + +const toState = (payload: unknown): string => { + if ( + typeof payload === "object" && + payload !== null && + "state" in payload && + typeof payload.state === "string" + ) { + return payload.state; + } + + return "unknown"; +}; + +export const confirmAlertRecipient = async ( + token?: string, +): Promise => { + const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + if (!apiBaseUrl) { + return { + ok: false, + state: "missing_api_base_url", + message: FALLBACK_CONFIRM_ERROR, + }; + } + + const url = new URL(`${apiBaseUrl}/alerts/recipients/confirm`); + if (token) { + url.searchParams.set("token", token); + } + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + const payload = (await response.json()) as AlertConfirmApiResponse; + + return { + ok: response.ok, + state: toState(payload), + message: toMessage(payload) ?? FALLBACK_CONFIRM_ERROR, + }; + } catch { + return { + ok: false, + state: "network_error", + message: FALLBACK_CONFIRM_ERROR, + }; + } +}; diff --git a/ui/app/(auth)/alerts/confirm/page.test.tsx b/ui/app/(auth)/alerts/confirm/page.test.tsx new file mode 100644 index 0000000000..8b6203c109 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/page.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import AlertsConfirmPage from "./page"; + +const confirmAlertRecipientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./confirm-alert-recipient", () => ({ + confirmAlertRecipient: confirmAlertRecipientMock, +})); + +vi.mock("@/components/auth/oss/auth-layout", () => ({ + AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/shadcn", () => ({ + Button: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +describe("AlertsConfirmPage", () => { + it("shows the API message after confirming the alert recipient", async () => { + // Given + confirmAlertRecipientMock.mockResolvedValueOnce({ + ok: true, + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }); + + // When + render( + await AlertsConfirmPage({ + searchParams: Promise.resolve({ token: "token-1" }), + }), + ); + + // Then + expect(confirmAlertRecipientMock).toHaveBeenCalledWith("token-1"); + expect(screen.getByLabelText("Subscription confirmed")).toBeInTheDocument(); + expect( + screen.getByText( + "Your subscription has been confirmed. You will receive alert digests at this address.", + ), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Continue to Prowler" }), + ).toHaveAttribute("href", "/"); + }); + + it("shows the subscription link title when confirmation fails", async () => { + // Given + confirmAlertRecipientMock.mockResolvedValueOnce({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + + // When + render( + await AlertsConfirmPage({ + searchParams: Promise.resolve({ token: ["expired-token"] }), + }), + ); + + // Then + expect(confirmAlertRecipientMock).toHaveBeenCalledWith("expired-token"); + expect(screen.getByLabelText("Subscription link")).toBeInTheDocument(); + expect( + screen.getByText("This link is invalid or has expired."), + ).toBeVisible(); + }); +}); diff --git a/ui/app/(auth)/alerts/confirm/page.tsx b/ui/app/(auth)/alerts/confirm/page.tsx new file mode 100644 index 0000000000..3791166d60 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { Button } from "@/components/shadcn"; + +import { confirmAlertRecipient } from "./confirm-alert-recipient"; + +interface AlertsConfirmPageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +const getParamValue = ( + params: Awaited, + key: string, +): string | undefined => { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +}; + +export default async function AlertsConfirmPage({ + searchParams, +}: AlertsConfirmPageProps) { + const resolvedSearchParams = await searchParams; + const token = getParamValue(resolvedSearchParams, "token"); + const result = await confirmAlertRecipient(token); + const title = result.ok ? "Subscription confirmed" : "Subscription link"; + + return ( + +
+

+ {result.message} +

+ +
+
+ ); +} diff --git a/ui/app/(auth)/alerts/unsubscribe/page.test.tsx b/ui/app/(auth)/alerts/unsubscribe/page.test.tsx new file mode 100644 index 0000000000..4ffeb548b0 --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/page.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import AlertsUnsubscribePage from "./page"; + +const unsubscribeAlertRecipientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./unsubscribe-alert-recipient", () => ({ + unsubscribeAlertRecipient: unsubscribeAlertRecipientMock, +})); + +vi.mock("@/components/auth/oss/auth-layout", () => ({ + AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/shadcn", () => ({ + Button: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +describe("AlertsUnsubscribePage", () => { + it("shows a neutral link back to the app after unsubscribing", async () => { + // Given + unsubscribeAlertRecipientMock.mockResolvedValueOnce({ + ok: true, + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }); + + // When + render( + await AlertsUnsubscribePage({ + searchParams: Promise.resolve({ token: "token-1" }), + }), + ); + + // Then + expect(unsubscribeAlertRecipientMock).toHaveBeenCalledWith("token-1"); + expect(screen.getByLabelText("Unsubscribed")).toBeInTheDocument(); + expect( + screen.getByText( + "You have been unsubscribed. You will not receive further alerts at this address.", + ), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Continue to Prowler" }), + ).toHaveAttribute("href", "/"); + }); +}); diff --git a/ui/app/(auth)/alerts/unsubscribe/page.tsx b/ui/app/(auth)/alerts/unsubscribe/page.tsx new file mode 100644 index 0000000000..e54798a6e9 --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { Button } from "@/components/shadcn"; + +import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient"; + +interface AlertsUnsubscribePageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +const getParamValue = ( + params: Awaited, + key: string, +): string | undefined => { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +}; + +export default async function AlertsUnsubscribePage({ + searchParams, +}: AlertsUnsubscribePageProps) { + const resolvedSearchParams = await searchParams; + const token = getParamValue(resolvedSearchParams, "token"); + const result = await unsubscribeAlertRecipient(token); + const title = result.ok ? "Unsubscribed" : "Subscription link"; + + return ( + +
+

+ {result.message} +

+ +
+
+ ); +} diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts new file mode 100644 index 0000000000..8a595fa63e --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient"; + +const fetchMock = vi.fn(); + +const lastFetchCall = (): { url: string; init: RequestInit } => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [url, init] = call; + return { url: String(url), init: (init ?? {}) as RequestInit }; +}; + +describe("unsubscribeAlertRecipient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("calls the public unsubscribe endpoint without auth headers", async () => { + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: true, + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "https://api.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + expect(init).toEqual({ + headers: { Accept: "application/json" }, + cache: "no-store", + }); + }); + + it("returns the API message for invalid tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "invalid_token", + message: "This link is invalid or has expired.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await unsubscribeAlertRecipient("expired-token"); + + // Then + expect(result).toEqual({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + }); + + it("returns the API message for missing tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "missing_token", + message: "This link is missing a token.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await unsubscribeAlertRecipient(); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_token", + message: "This link is missing a token.", + }); + expect(lastFetchCall().url).toBe( + "https://api.example.com/api/v1/alerts/recipients/unsubscribe", + ); + }); +}); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts new file mode 100644 index 0000000000..af64165a1e --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -0,0 +1,79 @@ +interface AlertUnsubscribeApiResponse { + state?: string; + message?: string; +} + +interface AlertUnsubscribeResult { + ok: boolean; + state: string; + message: string; +} + +const FALLBACK_UNSUBSCRIBE_ERROR = + "We could not process this unsubscribe link. Please try again later."; + +const toMessage = (payload: unknown): string | null => { + if ( + typeof payload === "object" && + payload !== null && + "message" in payload && + typeof payload.message === "string" + ) { + return payload.message; + } + + return null; +}; + +const toState = (payload: unknown): string => { + if ( + typeof payload === "object" && + payload !== null && + "state" in payload && + typeof payload.state === "string" + ) { + return payload.state; + } + + return "unknown"; +}; + +export const unsubscribeAlertRecipient = async ( + token?: string, +): Promise => { + const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + if (!apiBaseUrl) { + return { + ok: false, + state: "missing_api_base_url", + message: FALLBACK_UNSUBSCRIBE_ERROR, + }; + } + + const url = new URL(`${apiBaseUrl}/alerts/recipients/unsubscribe`); + if (token) { + url.searchParams.set("token", token); + } + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + const payload = (await response.json()) as AlertUnsubscribeApiResponse; + + return { + ok: response.ok, + state: toState(payload), + message: toMessage(payload) ?? FALLBACK_UNSUBSCRIBE_ERROR, + }; + } catch { + return { + ok: false, + state: "network_error", + message: FALLBACK_UNSUBSCRIBE_ERROR, + }; + } +}; diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 8759e86fc0..b7a4b3a5ae 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -138,4 +138,13 @@ describe("AccountsSelector", () => { screen.getByText("Production AWS").closest("[data-value]"), ).toHaveAttribute("data-keywords", expect.stringContaining("123456789012")); }); + + it("disables select all when every account is already shown", () => { + render(); + + expect( + screen.getByRole("option", { name: /select all accounts/i }), + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("All selected")).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 7d20c96355..e76700013b 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -171,18 +171,23 @@ export function AccountsSelector({
handleMultiValueChange([])} + className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50" + onClick={() => { + if (selectedIds.length === 0) return; + handleMultiValueChange([]); + }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); + if (selectedIds.length === 0) return; handleMultiValueChange([]); } }} > - Select All + {selectedIds.length === 0 ? "All selected" : "Select All"}
{visibleProviders.map((p) => { const id = p.id; diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index a60766394f..8307821992 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -135,4 +135,13 @@ describe("ProviderTypeSelector", () => { expect.stringContaining("Amazon Web Services"), ); }); + + it("disables select all when every provider is already shown", () => { + render(); + + expect( + screen.getByRole("option", { name: /select all providers/i }), + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("All selected")).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 04af59d0ba..f71245af30 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -295,18 +295,23 @@ export const ProviderTypeSelector = ({
handleMultiValueChange([])} + className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50" + onClick={() => { + if (selectedTypes.length === 0) return; + handleMultiValueChange([]); + }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); + if (selectedTypes.length === 0) return; handleMultiValueChange([]); } }} > - Select All + {selectedTypes.length === 0 ? "All selected" : "Select All"}
{availableTypes.map((providerType) => ( ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types"; +import { + createAlert, + deleteAlert, + disableAlert, + enableAlert, + listAlerts, + previewAlertCondition, + seedAlertRule, + updateAlert, +} from "./alerts"; + +const lastFetchCall = (): { url: string; init: RequestInit } => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [url, init] = call; + return { url: String(url), init: (init ?? {}) as RequestInit }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/vnd.api+json" }, + }), + ); + getAuthHeadersMock.mockResolvedValue({ + Accept: "application/vnd.api+json", + Authorization: "Bearer test-token", + "Content-Type": "application/vnd.api+json", + }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error." }); +}); + +describe("listAlerts", () => { + it("returns whatever handleApiResponse returns", async () => { + handleApiResponseMock.mockResolvedValue({ + data: [], + meta: { pagination: { count: 0 } }, + }); + const result = await listAlerts({ "filter[enabled]": "true" }); + expect(result).toEqual({ data: [], meta: { pagination: { count: 0 } } }); + }); + + it("forwards searchParams as query string", async () => { + await listAlerts({ "filter[trigger]": "daily" }); + expect(lastFetchCall().url).toContain("filter%5Btrigger%5D=daily"); + }); + + it("delegates network errors to handleApiError", async () => { + fetchMock.mockRejectedValueOnce(new Error("boom")); + handleApiErrorMock.mockReturnValueOnce({ error: "boom" }); + const result = await listAlerts(); + expect(handleApiErrorMock).toHaveBeenCalled(); + expect(result).toEqual({ error: "boom" }); + }); +}); + +describe("createAlert", () => { + it("posts a JSON:API envelope with schema_version", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + id: "alert-1", + type: "alert-rules", + attributes: { name: "n", trigger: "after_scan" }, + }, + }); + await createAlert({ + name: "Daily critical", + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + }); + const { init } = lastFetchCall(); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body.data.type).toBe("alert-rules"); + expect(body.data.attributes.schema_version).toBe(1); + }); + + it("sends an empty recipient list when provided", async () => { + await createAlert({ + name: "No recipients yet", + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + recipientEmails: [], + }); + const body = JSON.parse(lastFetchCall().init.body as string); + expect(body.data.attributes.recipient_emails).toEqual([]); + }); +}); + +describe("seedAlertRule", () => { + it("posts a JSON:API seeding envelope to /seed", async () => { + const filterBag = { + "filter[severity__in]": "critical", + "filter[sort]": "-severity", + }; + await seedAlertRule(filterBag); + const { url, init } = lastFetchCall(); + expect(url).toMatch(/\/alerts\/rules\/seed$/); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + data: { + type: "alert-rule-seedings", + attributes: { filter_bag: filterBag }, + }, + }); + }); +}); + +describe("updateAlert", () => { + it("PATCHes the alert with the id in the URL", async () => { + await updateAlert("alert-1", { + name: "Updated", + trigger: ALERT_TRIGGER_KINDS.DAILY, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + }); + const { url, init } = lastFetchCall(); + expect(url).toContain("/alerts/rules/alert-1"); + expect(init.method).toBe("PATCH"); + }); +}); + +describe("deleteAlert", () => { + it("issues a DELETE against the alert id", async () => { + handleApiResponseMock.mockResolvedValue({ success: true, status: 204 }); + await deleteAlert("alert-1"); + const { init } = lastFetchCall(); + expect(init.method).toBe("DELETE"); + }); +}); + +describe("enable / disable", () => { + it("PATCHes enabled true to the alert rule endpoint", async () => { + await enableAlert("alert-1"); + const { url, init } = lastFetchCall(); + expect(url).toMatch(/\/alerts\/rules\/alert-1$/); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ + data: { + type: "alert-rules", + id: "alert-1", + attributes: { enabled: true }, + }, + }); + }); + + it("PATCHes enabled false to the alert rule endpoint", async () => { + await disableAlert("alert-1"); + const { url, init } = lastFetchCall(); + expect(url).toMatch(/\/alerts\/rules\/alert-1$/); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ + data: { + type: "alert-rules", + id: "alert-1", + attributes: { enabled: false }, + }, + }); + }); +}); + +describe("previewAlertCondition", () => { + it("posts a JSON:API preview envelope to /preview", async () => { + const condition = { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }; + await previewAlertCondition({ condition }); + const { url, init } = lastFetchCall(); + expect(url).toMatch(/\/alerts\/rules\/preview$/); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual( + expect.objectContaining({ + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + }), + ); + expect(JSON.parse(init.body as string)).toEqual({ + data: { + type: "alert-rule-previews", + attributes: { condition }, + }, + }); + }); +}); diff --git a/ui/app/(prowler)/alerts/_actions/alerts.ts b/ui/app/(prowler)/alerts/_actions/alerts.ts new file mode 100644 index 0000000000..a260636c0b --- /dev/null +++ b/ui/app/(prowler)/alerts/_actions/alerts.ts @@ -0,0 +1,214 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; + +import { + ALERT_SCHEMA_VERSION, + type AlertCondition, + type AlertTriggerKind, +} from "../_types"; + +const ALERT_RULES_API_PATH = "/alerts/rules"; +const ALERTS_REVALIDATE_PATH = "/alerts"; + +export interface AlertPayload { + name: string; + description?: string; + enabled?: boolean; + trigger: AlertTriggerKind; + condition: AlertCondition; + /** + * List of recipient email addresses. The API resolves them to existing + * `AlertRecipient` rows or creates new pending ones with confirmation + * emails. Recipient IDs are NOT used by the rule write path. + */ + recipientEmails?: string[]; +} + +const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({ + data: { + type: "alert-rules", + ...(alertId ? { id: alertId } : {}), + attributes: { + name: payload.name, + description: payload.description ?? "", + enabled: payload.enabled ?? true, + trigger: payload.trigger, + condition: payload.condition, + schema_version: ALERT_SCHEMA_VERSION, + ...(payload.recipientEmails !== undefined + ? { recipient_emails: payload.recipientEmails } + : {}), + }, + }, +}); + +const buildEnabledEnvelope = (alertId: string, enabled: boolean) => ({ + data: { + type: "alert-rules", + id: alertId, + attributes: { enabled }, + }, +}); + +const buildSeedEnvelope = (filterBag: Record) => ({ + data: { + type: "alert-rule-seedings", + attributes: { filter_bag: filterBag }, + }, +}); + +export const listAlerts = async ( + searchParams?: Record, +) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`); + + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + if (value !== undefined && value !== "") { + url.searchParams.append(key, value); + } + } + } + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const getAlert = async (alertId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const seedAlertRule = async ( + filterBag: Record, +) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/seed`); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(buildSeedEnvelope(filterBag)), + }); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const createAlert = async (payload: AlertPayload) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(buildRuleEnvelope(payload)), + }); + return handleApiResponse(response, ALERTS_REVALIDATE_PATH); + } catch (error) { + return handleApiError(error); + } +}; + +export const updateAlert = async (alertId: string, payload: AlertPayload) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`); + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(buildRuleEnvelope(payload, alertId)), + }); + return handleApiResponse(response, ALERTS_REVALIDATE_PATH); + } catch (error) { + return handleApiError(error); + } +}; + +export const deleteAlert = async (alertId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + return handleApiResponse(response, ALERTS_REVALIDATE_PATH); + } catch (error) { + return handleApiError(error); + } +}; + +export const enableAlert = async (alertId: string) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`); + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(buildEnabledEnvelope(alertId, true)), + }); + return handleApiResponse(response, ALERTS_REVALIDATE_PATH); + } catch (error) { + return handleApiError(error); + } +}; + +export const disableAlert = async (alertId: string) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`); + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(buildEnabledEnvelope(alertId, false)), + }); + return handleApiResponse(response, ALERTS_REVALIDATE_PATH); + } catch (error) { + return handleApiError(error); + } +}; + +export const previewAlertCondition = async (payload: { + condition: AlertCondition; +}) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/preview`); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + data: { + type: "alert-rule-previews", + attributes: { condition: payload.condition }, + }, + }), + }); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(prowler)/alerts/_actions/index.ts b/ui/app/(prowler)/alerts/_actions/index.ts new file mode 100644 index 0000000000..e4aa1f6160 --- /dev/null +++ b/ui/app/(prowler)/alerts/_actions/index.ts @@ -0,0 +1,2 @@ +export * from "./alerts"; +export * from "./recipients"; diff --git a/ui/app/(prowler)/alerts/_actions/recipients.test.ts b/ui/app/(prowler)/alerts/_actions/recipients.test.ts new file mode 100644 index 0000000000..1a51a0d34a --- /dev/null +++ b/ui/app/(prowler)/alerts/_actions/recipients.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { listAlertRecipients } from "./recipients"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/vnd.api+json" }, + }), + ); + getAuthHeadersMock.mockResolvedValue({ + Accept: "application/vnd.api+json", + Authorization: "Bearer test-token", + }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error." }); +}); + +describe("listAlertRecipients", () => { + it("returns whatever handleApiResponse returns", async () => { + handleApiResponseMock.mockResolvedValue({ + data: [ + { + id: "1", + type: "alert-recipients", + attributes: { email: "a@b.test", status: "pending" }, + }, + ], + meta: { pagination: { count: 1, page: 1, pages: 1 } }, + }); + const result = await listAlertRecipients({ + "filter[status]": "pending", + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].attributes.email).toBe("a@b.test"); + }); + + it("forwards searchParams as query string", async () => { + await listAlertRecipients({ "filter[status]": "pending" }); + const [url] = fetchMock.mock.calls.at(-1) ?? [""]; + expect(String(url)).toContain("filter%5Bstatus%5D=pending"); + }); +}); diff --git a/ui/app/(prowler)/alerts/_actions/recipients.ts b/ui/app/(prowler)/alerts/_actions/recipients.ts new file mode 100644 index 0000000000..8d528b19f7 --- /dev/null +++ b/ui/app/(prowler)/alerts/_actions/recipients.ts @@ -0,0 +1,28 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; + +const RECIPIENTS_PATH = "/alerts/recipients"; + +export const listAlertRecipients = async ( + searchParams?: Record, +) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${RECIPIENTS_PATH}`); + + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + if (value !== undefined && value !== "") { + url.searchParams.append(key, value); + } + } + } + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx new file mode 100644 index 0000000000..c3f37cfd45 --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -0,0 +1,755 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ALERT_AGGREGATE_OPS, + ALERT_BOOLEAN_OPS, + ALERT_RECIPIENT_STATUS, + ALERT_TRIGGER_KINDS, + type AlertCondition, + type AlertRecipient, + type AlertRule, +} from "@/app/(prowler)/alerts/_types"; +import type { ProviderProps } from "@/types/providers"; + +import { AlertFormModal } from "../alert-form-modal"; + +const recipientsActionMocks = vi.hoisted(() => ({ + listAlertRecipients: vi.fn(), +})); + +const alertsActionMocks = vi.hoisted(() => ({ + previewAlertCondition: vi.fn(), + seedAlertRule: vi.fn(), +})); + +vi.mock( + "@/app/(prowler)/alerts/_actions/recipients", + () => recipientsActionMocks, +); + +vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks); + +vi.mock( + "@/components/compliance/compliance-header/compliance-scan-info", + () => ({ + ComplianceScanInfo: () => Scan, + }), +); + +vi.mock("@/components/ui/entities/entity-info", () => ({ + EntityInfo: ({ + entityAlias, + entityId, + }: { + entityAlias?: string; + entityId?: string; + }) => {entityAlias ?? entityId}, +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: null, status: "unauthenticated" }), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/alerts", + useRouter: () => ({ replace: vi.fn(), push: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/components/shadcn/modal", () => ({ + Modal: ({ + open, + title, + description, + className, + onOpenAutoFocus, + children, + }: { + open: boolean; + title?: string; + description?: string; + className?: string; + onOpenAutoFocus?: (event: Event) => void; + children: ReactNode; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +global.ResizeObserver = ResizeObserverMock; +Element.prototype.scrollIntoView = vi.fn(); + +const mockProviders: ProviderProps[] = [ + { + id: "provider-aws-1", + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production AWS", + status: "completed", + resources: 42, + connection: { + connected: true, + last_checked_at: "2026-04-30T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-30T00:00:00Z", + updated_at: "2026-04-30T00:00:00Z", + created_by: { object: "users", id: "user-1" }, + }, + relationships: { + secret: { data: null }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + }, + { + id: "provider-gcp-1", + type: "providers", + attributes: { + provider: "gcp", + uid: "prowler-prod-project", + alias: "Production GCP", + status: "completed", + resources: 21, + connection: { + connected: true, + last_checked_at: "2026-04-30T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-30T00:00:00Z", + updated_at: "2026-04-30T00:00:00Z", + created_by: { object: "users", id: "user-1" }, + }, + relationships: { + secret: { data: null }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + }, +]; + +const createRecipient = ( + id: string, + email: string, + status: AlertRecipient["attributes"]["status"], +): AlertRecipient => ({ + id, + type: "alert-recipients", + attributes: { + email, + status, + inserted_at: "2026-04-30T00:00:00Z", + updated_at: "2026-04-30T00:00:00Z", + }, + relationships: { rules: { data: [] } }, +}); + +const confirmedRecipient = createRecipient( + "recipient-confirmed", + "security@example.com", + ALERT_RECIPIENT_STATUS.CONFIRMED, +); + +const pendingRecipient = createRecipient( + "recipient-pending", + "pending@example.com", + ALERT_RECIPIENT_STATUS.PENDING, +); + +const createEditingAlert = ( + overrides: Partial = {}, +): AlertRule => ({ + id: "alert-1", + type: "alert-rules", + attributes: { + name: "Existing alert", + description: "Existing description", + enabled: true, + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.COUNT_GTE, + filter: { severity: ["critical"] }, + value: 1, + }, + schema_version: 1, + recipient_emails: ["security@example.com"], + inserted_at: "2026-04-30T00:00:00Z", + updated_at: "2026-04-30T00:00:00Z", + ...overrides, + }, +}); + +const mockRecipientsList = () => { + recipientsActionMocks.listAlertRecipients.mockResolvedValue({ + data: [confirmedRecipient, pendingRecipient], + meta: { pagination: { page: 1, pages: 1, count: 2 } }, + }); +}; + +const renderCreateModal = ( + props: Partial> = {}, +) => + render( + , + ); + +const getVisibleFilterTrigger = (label: string): HTMLButtonElement => { + const trigger = screen + .getAllByRole("combobox") + .find( + (element) => + element.textContent?.includes(label) && + !element.closest('[aria-hidden="true"]'), + ); + + expect(trigger).toBeDefined(); + return trigger as HTMLButtonElement; +}; + +describe("AlertFormModal", () => { + beforeEach(() => { + recipientsActionMocks.listAlertRecipients.mockReset(); + recipientsActionMocks.listAlertRecipients.mockReturnValue( + new Promise(() => {}), + ); + alertsActionMocks.previewAlertCondition.mockReset(); + alertsActionMocks.seedAlertRule.mockReset(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { provider_type: ["gcp"] }, + }, + schema_version: 1, + warnings: [], + }, + }, + }); + }); + + it("should render the simplified alert form without preview, delivery settings, or nested recipient management", () => { + // Given / When + renderCreateModal({ + providers: mockProviders, + uniqueRegions: ["us-east-1", "europe-west1"], + uniqueServices: ["iam", "cloudsql"], + uniqueCategories: ["identity-security"], + uniqueGroups: ["prod"], + }); + + // Then + expect(screen.getByRole("dialog", { name: "Create Alert" })).toBeVisible(); + expect(screen.getByLabelText(/^name$/i)).toBeVisible(); + expect(screen.getByLabelText(/^description$/i)).toBeVisible(); + expect(screen.getByLabelText(/^frequency$/i)).toBeVisible(); + expect(screen.getByLabelText(/^recipients$/i)).toBeVisible(); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument(); + expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/notification method/i), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /manage recipients/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Production AWS")).not.toBeInTheDocument(); + expect(screen.queryByText(/resource type/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument(); + }); + + it("should provide accessible dialog description and allow initial focus when editing", () => { + // Given / When + renderCreateModal({ + editingAlert: createEditingAlert(), + }); + + // Then + const dialog = screen.getByRole("dialog", { name: "Edit Alert" }); + expect(dialog).toHaveAccessibleDescription( + "Update recipients, frequency, and finding filters for this alert.", + ); + expect(dialog).toHaveAttribute("data-allows-open-auto-focus", "true"); + }); + + it("should show selected Findings filters as chips while keeping criteria controls hidden", () => { + // Given / When + renderCreateModal({ + seededCondition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + selectedFindingsFilterChips: [ + { key: "filter[status__in]", label: "Status", value: "FAIL" }, + { key: "filter[muted]", label: "Muted", value: "false" }, + ], + }); + + // Then + expect( + screen.getByRole("region", { name: /active filters/i }), + ).toHaveTextContent("Status: FAIL"); + expect( + screen.getByRole("region", { name: /active filters/i }), + ).toHaveTextContent("Muted: false"); + expect(screen.queryByText("All Provider")).not.toBeInTheDocument(); + expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument(); + }); + + it("should list tenant recipients with status and submit selected emails", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi + .fn() + .mockResolvedValue({ ok: true, alertId: "alert-1" }); + mockRecipientsList(); + renderCreateModal({ onSubmit }); + + // When + await user.type(screen.getByLabelText(/^name$/i), "Critical alerts"); + await user.click(getVisibleFilterTrigger("Select emails")); + expect((await screen.findAllByText("Confirmed")).at(-1)).toBeVisible(); + expect(screen.getAllByText("Pending").at(-1)).toBeVisible(); + const recipientOptions = await screen.findAllByText("pending@example.com"); + const visibleRecipientOption = recipientOptions.at(-1); + expect(visibleRecipientOption).toBeDefined(); + await user.click(visibleRecipientOption as HTMLElement); + await user.click(screen.getByRole("button", { name: /^create$/i })); + + // Then + expect(screen.getAllByText("pending@example.com").at(-1)).toBeVisible(); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN, + recipientEmails: ["pending@example.com"], + }), + ), + ); + const recipientsParams = recipientsActionMocks.listAlertRecipients.mock + .calls[0][0] as Record; + expect(recipientsParams["filter[status]"]).toBeUndefined(); + expect(recipientsParams["page[size]"]).toBe("100"); + }); + + it("should submit the configured alert frequency", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi + .fn() + .mockResolvedValue({ ok: true, alertId: "alert-1" }); + mockRecipientsList(); + renderCreateModal({ + defaultFrequency: ALERT_TRIGGER_KINDS.DAILY, + onSubmit, + }); + + // When + await user.type(screen.getByLabelText(/^name$/i), "Daily alerts"); + expect( + screen.getByRole("combobox", { name: /frequency/i }), + ).toHaveTextContent("Daily digest"); + await user.click(getVisibleFilterTrigger("Select emails")); + const recipientOptions = await screen.findAllByText("security@example.com"); + const visibleRecipientOption = recipientOptions.at(-1); + expect(visibleRecipientOption).toBeDefined(); + await user.click(visibleRecipientOption as HTMLElement); + await user.click(screen.getByRole("button", { name: /^create$/i })); + + // Then + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + frequency: ALERT_TRIGGER_KINDS.DAILY, + }), + ), + ); + }); + + it("should allow submitting without selected recipients", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi + .fn() + .mockResolvedValue({ ok: true, alertId: "alert-1" }); + mockRecipientsList(); + renderCreateModal({ onSubmit }); + + // When + await user.type(screen.getByLabelText(/^name$/i), "Critical alerts"); + await user.click(screen.getByRole("button", { name: /^create$/i })); + + // Then + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + recipientEmails: [], + }), + ), + ); + expect( + screen.queryByText(/select at least one recipient/i), + ).not.toBeInTheDocument(); + }); + + it("should render backend submit errors with the design error color", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue({ + ok: false, + error: "Backend validation failed", + }); + mockRecipientsList(); + renderCreateModal({ onSubmit }); + + // When + await user.type(screen.getByLabelText(/^name$/i), "Critical alerts"); + await user.click(screen.getByRole("button", { name: /^create$/i })); + + // Then + const errorMessage = await screen.findByText("Backend validation failed"); + expect(errorMessage).toHaveClass("text-text-error-primary"); + }); + + it("should reset form defaults when opening a different alert", () => { + // Given + const { rerender } = render( + , + ); + + // When + rerender( + , + ); + + // Then + expect(screen.getByLabelText(/^name$/i)).toHaveValue("Second alert"); + }); + + it("should render the shared Findings batch filter controls for an existing alert", async () => { + // Given + mockRecipientsList(); + renderCreateModal({ + editingAlert: createEditingAlert({ + condition: { + op: ALERT_BOOLEAN_OPS.AND, + children: [ + { + op: ALERT_AGGREGATE_OPS.COUNT_GTE, + filter: { severity: ["critical"] }, + value: 1, + }, + { + op: ALERT_AGGREGATE_OPS.COUNT_GTE, + filter: { provider_type: ["aws"] }, + value: 1, + }, + ], + }, + }), + providers: mockProviders, + uniqueRegions: ["us-east-1", "europe-west1"], + uniqueServices: ["iam", "cloudsql"], + uniqueResourceTypes: ["AWS::IAM::User"], + uniqueCategories: ["identity-security"], + uniqueGroups: ["prod"], + }); + + // Then + const recipientsTrigger = screen.getByLabelText(/^recipients$/i); + const filtersHeading = screen.getByRole("heading", { name: /^filters$/i }); + + expect(filtersHeading).toBeVisible(); + expect( + recipientsTrigger.compareDocumentPosition(filtersHeading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(filtersHeading.closest('[data-slot="card"]')).toBeVisible(); + const filterControls = screen.getByTestId("findings-filter-controls"); + const alertEditGrid = filterControls.querySelector(".grid"); + expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3"); + expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5"); + expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible(); + expect(screen.getByText("All accounts")).toBeVisible(); + expect(within(filterControls).getByText("All Delta")).toBeVisible(); + expect(within(filterControls).getByText("All Resource Type")).toBeVisible(); + expect( + screen.queryByTestId("findings-expanded-filters"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /more filters/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("All Status")).not.toBeInTheDocument(); + expect(screen.queryByText("Scan ID")).not.toBeInTheDocument(); + expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^severity$/i)).not.toBeInTheDocument(); + }); + + it("should save edited filters as a normalized simple condition", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi + .fn() + .mockResolvedValue({ ok: true, alertId: "alert-1" }); + mockRecipientsList(); + renderCreateModal({ + editingAlert: createEditingAlert(), + providers: mockProviders, + onSubmit, + }); + + // When + await user.click(screen.getByLabelText(/provider type/i)); + const providerOptions = await screen.findAllByText("Google Cloud Platform"); + const visibleProviderOption = providerOptions.at(-1); + expect(visibleProviderOption).toBeDefined(); + await user.click(visibleProviderOption as HTMLElement); + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + await waitFor(() => + expect(alertsActionMocks.seedAlertRule).toHaveBeenCalled(), + ); + expect(alertsActionMocks.seedAlertRule).toHaveBeenCalledWith( + expect.objectContaining({ + "filter[provider_type__in]": ["gcp"], + }), + ); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + condition: expect.objectContaining({ + filter: { provider_type: ["gcp"] }, + }), + }), + ), + ); + }); + + it("should preview the edited alert using current unsaved filters", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.previewAlertCondition.mockResolvedValue({ + data: { + attributes: { + summary: { + finding_count_total: 7, + top_severity: "critical", + }, + sample_finding_ids: [], + evaluation_failed: false, + duration_ms: 42, + }, + }, + }); + mockRecipientsList(); + renderCreateModal({ + editingAlert: createEditingAlert(), + providers: mockProviders, + }); + + // When + await user.click(screen.getByLabelText(/provider type/i)); + const providerOptions = await screen.findAllByText("Google Cloud Platform"); + const visibleProviderOption = providerOptions.at(-1); + expect(visibleProviderOption).toBeDefined(); + await user.click(visibleProviderOption as HTMLElement); + await user.click(screen.getByRole("button", { name: /^test$/i })); + + // Then + await waitFor(() => + expect(alertsActionMocks.seedAlertRule).toHaveBeenCalledWith( + expect.objectContaining({ + "filter[provider_type__in]": ["gcp"], + }), + ), + ); + await waitFor(() => + expect(alertsActionMocks.previewAlertCondition).toHaveBeenCalledWith( + expect.objectContaining({ + condition: expect.objectContaining({ + filter: { provider_type: ["gcp"] }, + }), + }), + ), + ); + const previewHeading = await screen.findByText("Test result"); + expect(previewHeading).toBeVisible(); + const previewCard = previewHeading.closest('[data-slot="card"]'); + expect(previewCard).toBeInTheDocument(); + const previewCardQueries = within(previewCard as HTMLElement); + expect( + previewCardQueries.getByText( + "It found 7 findings, including Critical severity.", + ), + ).toBeVisible(); + expect( + previewCardQueries.queryByText(/^findings$/i), + ).not.toBeInTheDocument(); + expect( + previewCardQueries.queryByText(/^top severity$/i), + ).not.toBeInTheDocument(); + expect( + previewCardQueries.queryByText(/^duration$/i), + ).not.toBeInTheDocument(); + expect(previewCardQueries.queryByText(/42 ms/i)).not.toBeInTheDocument(); + expect( + previewCardQueries.queryByText("Would fire"), + ).not.toBeInTheDocument(); + expect( + previewCardQueries.queryByText("Would not fire"), + ).not.toBeInTheDocument(); + }); + + it("should explain when the edited alert has no matching findings", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.previewAlertCondition.mockResolvedValue({ + data: { + attributes: { + summary: { + finding_count_total: 0, + }, + sample_finding_ids: [], + evaluation_failed: false, + }, + }, + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^test$/i })); + + // Then + expect( + await screen.findByText( + "These filters did not match any findings for the latest scan.", + ), + ).toBeVisible(); + expect(screen.queryByText("Would fire")).not.toBeInTheDocument(); + expect(screen.queryByText("Would not fire")).not.toBeInTheDocument(); + }); + + it("should render preview errors inline in edit mode", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.previewAlertCondition.mockResolvedValue({ + error: "Invalid condition", + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^test$/i })); + + // Then + const errorMessage = await screen.findByText(/invalid condition/i); + expect(errorMessage).toBeVisible(); + expect(errorMessage).toHaveClass("text-text-error-primary"); + }); + + it("should hydrate advanced edit mode filters and normalize them on save", async () => { + // Given + const user = userEvent.setup(); + const advancedCondition: AlertCondition = { + op: ALERT_BOOLEAN_OPS.NOT, + child: { + op: ALERT_AGGREGATE_OPS.COUNT_GTE, + filter: { severity: ["critical"] }, + value: 1, + }, + }; + const onSubmit = vi + .fn() + .mockResolvedValue({ ok: true, alertId: "alert-1" }); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + schema_version: 1, + warnings: [], + }, + }, + }); + mockRecipientsList(); + renderCreateModal({ + editingAlert: createEditingAlert({ + condition: advancedCondition, + recipient_emails: ["security@example.com"], + }), + onSubmit, + }); + + // When + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Existing alert", + recipientEmails: ["security@example.com"], + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + }), + ), + ); + expect( + screen.queryByText(/advanced condition preserved/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx new file mode 100644 index 0000000000..f62f9b43f2 --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx @@ -0,0 +1,305 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ALERT_AGGREGATE_OPS, + ALERT_TRIGGER_KINDS, + type AlertRule, +} from "@/app/(prowler)/alerts/_types"; + +import { AlertsManager } from "../alerts-manager"; + +const actionMocks = vi.hoisted(() => ({ + deleteAlert: vi.fn(), + disableAlert: vi.fn(), + enableAlert: vi.fn(), + updateAlert: vi.fn(), +})); + +const routerMocks = vi.hoisted(() => ({ + refresh: vi.fn(), + replace: vi.fn(), + push: vi.fn(), + currentSearch: "", +})); + +const toastMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/app/(prowler)/alerts/_actions", () => actionMocks); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + className, + }: { + children: ReactNode; + href: string; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("@/lib", () => ({ + cn: (...classes: Array) => + classes.filter(Boolean).join(" "), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/alerts", + useRouter: () => routerMocks, + useSearchParams: () => new URLSearchParams(routerMocks.currentSearch), +})); + +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/components/shadcn", () => ({ + Button: ({ + asChild, + children, + disabled, + onClick, + variant, + }: { + asChild?: boolean; + children: ReactNode; + disabled?: boolean; + onClick?: () => void; + variant?: string; + }) => { + if (asChild && isValidElement(children)) { + return {children}; + } + + return ( + + ); + }, +})); + +vi.mock("../alert-form-modal", () => ({ + AlertFormModal: ({ + open, + editingAlert, + onOpenChange, + }: { + open: boolean; + editingAlert?: AlertRule | null; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ + {editingAlert?.attributes.name} +
+ ) : null, +})); + +vi.mock("../alerts-empty-state", () => ({ + AlertsEmptyState: () =>
No alerts
, +})); + +const makeAlert = (enabled: boolean): AlertRule => ({ + id: enabled ? "enabled-alert" : "disabled-alert", + type: "alert-rules", + attributes: { + name: enabled ? "Enabled alert" : "Disabled alert", + description: "", + enabled, + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + schema_version: 1, + recipient_emails: [], + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, +}); + +const renderManager = (alerts: AlertRule[]) => + render( + , + ); + +describe("AlertsManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + routerMocks.currentSearch = ""; + }); + + it("links to Findings from the alerts description", () => { + // Given + renderManager([]); + + // When + const findingsLink = screen.getByRole("link", { name: "Findings" }); + + // Then + expect(findingsLink).toHaveAttribute( + "href", + "/findings?filter[muted]=false&filter[status__in]=FAIL", + ); + expect(findingsLink.closest("[data-variant='link']")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "here." })).toHaveAttribute( + "href", + "https://docs.prowler.com/user-guide/tutorials/prowler-app", + ); + expect(screen.getByText(/get notified when findings match/i)).toBeVisible(); + }); + + it("opens the edit modal for an initial editing alert", () => { + // Given + const alert = makeAlert(true); + + // When + render( + , + ); + + // Then + expect( + screen.getByRole("dialog", { name: /edit alert/i }), + ).toHaveTextContent("Enabled alert"); + }); + + it("adds the edit alert id to the URL when opening the edit modal", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + routerMocks.currentSearch = "page=2&filter[enabled]=true"; + renderManager([alert]); + + // When + await user.click( + screen.getByRole("button", { name: /actions for enabled alert/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /edit/i })); + + // Then + expect(routerMocks.replace).toHaveBeenCalledWith( + "/alerts?page=2&filter%5Benabled%5D=true&edit=enabled-alert", + { scroll: false }, + ); + expect( + screen.getByRole("dialog", { name: /edit alert/i }), + ).toHaveTextContent("Enabled alert"); + }); + + it("removes only the edit alert id from the URL when closing the edit modal", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + routerMocks.currentSearch = "page=2&edit=enabled-alert"; + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /close modal/i })); + + // Then + expect(routerMocks.replace).toHaveBeenCalledWith("/alerts?page=2", { + scroll: false, + }); + }); + + it("shows a success toast after disabling an alert", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + actionMocks.disableAlert.mockResolvedValue({ data: alert }); + renderManager([alert]); + + // When + await user.click( + screen.getByRole("button", { name: /actions for enabled alert/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /disable/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Alert disabled", + description: "Enabled alert", + }), + ); + }); + + it("shows a success toast after enabling an alert", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(false); + actionMocks.enableAlert.mockResolvedValue({ data: alert }); + renderManager([alert]); + + // When + await user.click( + screen.getByRole("button", { name: /actions for disabled alert/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /enable/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Alert enabled", + description: "Disabled alert", + }), + ); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alerts-table.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alerts-table.test.tsx new file mode 100644 index 0000000000..3cea620aa4 --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/__tests__/alerts-table.test.tsx @@ -0,0 +1,253 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ALERT_AGGREGATE_OPS, + ALERT_TRIGGER_KINDS, + type AlertRule, +} from "@/app/(prowler)/alerts/_types"; + +import { AlertsTable } from "../alerts-table"; + +const navigationMocks = vi.hoisted(() => ({ + routerPush: vi.fn(), + currentSearch: "", +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/alerts", + useRouter: () => ({ push: navigationMocks.routerPush }), + useSearchParams: () => new URLSearchParams(navigationMocks.currentSearch), +})); + +vi.mock("@/components/ui/table/data-table", () => ({ + DataTable: ({ + columns, + data, + metadata, + }: { + columns: { + id?: string; + size?: number; + minSize?: number; + cell?: (context: { row: { original: AlertRule } }) => ReactNode; + }[]; + data: AlertRule[]; + metadata?: { pagination?: { count?: number } }; + }) => ( +
+ {metadata?.pagination?.count !== undefined && ( + {metadata.pagination.count} Total Entries + )} + + + + {columns.map((column) => ( + + ))} + + + + {data.map((alert) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ +
+ {column.cell?.({ row: { original: alert } })} +
+
+ ), +})); + +vi.mock("@/components/ui/table/data-table-column-header", () => ({ + DataTableColumnHeader: ({ title }: { title: string }) => {title}, +})); + +interface AlertRuleOverrides extends Partial> { + attributes?: Partial; +} + +const makeRule = (overrides: AlertRuleOverrides = {}): AlertRule => ({ + id: overrides.id ?? "alert-1", + type: "alert-rules", + attributes: { + name: "Critical findings", + description: "Notify security", + enabled: true, + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + schema_version: 1, + recipient_emails: ["security@example.com"], + inserted_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-02T11:30:00Z", + ...overrides.attributes, + }, +}); + +describe("AlertsTable", () => { + beforeEach(() => { + navigationMocks.currentSearch = ""; + navigationMocks.routerPush.mockClear(); + }); + + it("should render alert rows with dropdown actions and shared pagination", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("cell", { name: /critical findings/i }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: /actions for critical findings/i }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: /edit critical findings/i }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/12 total entries/i)).toBeVisible(); + expect(screen.getByTestId("column-actions")).toHaveAttribute( + "data-size", + "72", + ); + expect(screen.getByTestId("column-name")).toHaveAttribute( + "data-size", + "320", + ); + expect(screen.getByTestId("column-inserted_at")).toHaveAttribute( + "data-size", + "170", + ); + expect(screen.getByTestId("column-updated_at")).toHaveAttribute( + "data-size", + "170", + ); + expect(screen.getByText("Jan 01, 2026")).toBeVisible(); + expect(screen.getByText("Jan 02, 2026")).toBeVisible(); + expect( + screen.queryByRole("button", { name: /run preview|test/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: /critical findings/i }), + ).not.toBeInTheDocument(); + }); + + it("should truncate long descriptions in the name column", () => { + // Given + const description = + "This alert description is intentionally long enough to overflow the alerts table if it is not constrained by the cell renderer."; + + // When + render( + , + ); + + // Then + expect(screen.getByText(description)).toHaveClass("truncate"); + expect(screen.getByText(description).parentElement).toHaveClass( + "max-w-[320px]", + ); + expect(screen.getByText(description)).toHaveAttribute("title", description); + }); + + it("should call row action callbacks for edit, toggle, and delete", async () => { + // Given + const user = userEvent.setup(); + const alert = makeRule({ id: "alert-enabled" }); + const onEdit = vi.fn(); + const onToggleEnabled = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /edit/i })); + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /disable/i })); + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /delete/i })); + + // Then + expect(onEdit).toHaveBeenCalledWith(alert); + expect(onToggleEnabled).toHaveBeenCalledWith(alert); + expect(onDelete).toHaveBeenCalledWith(alert); + }); + + it("should edit the alert directly when clicking the alert name", async () => { + // Given + const user = userEvent.setup(); + const alert = makeRule(); + const onEdit = vi.fn(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Critical findings" })); + + // Then + expect(onEdit).toHaveBeenCalledWith(alert); + expect(screen.queryByRole("menuitem", { name: /edit/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /disable/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /delete/i })).toBeNull(); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx new file mode 100644 index 0000000000..b6fca816df --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx @@ -0,0 +1,384 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps, ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AlertCondition } from "@/app/(prowler)/alerts/_types"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "@/app/(prowler)/alerts/_types/alert-form"; + +const routerMocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), +})); + +const actionMocks = vi.hoisted(() => ({ + createAlert: vi.fn(), + seedAlertRule: vi.fn(), +})); + +const toastMock = vi.hoisted(() => vi.fn()); + +vi.mock("next/navigation", () => ({ + useRouter: () => routerMocks, +})); + +vi.mock("@/components/ui", () => ({ + ToastAction: ({ + asChild, + children, + ...props + }: ComponentProps<"button"> & { + asChild?: boolean; + children?: ReactNode; + }) => (asChild ? children : ), + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/app/(prowler)/alerts/_actions", () => ({ + createAlert: actionMocks.createAlert, + seedAlertRule: actionMocks.seedAlertRule, +})); + +vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({ + AlertFormModal: ({ + open, + seededCondition, + selectedFindingsFilterChips, + defaultName, + onSubmit, + }: { + open: boolean; + seededCondition?: AlertCondition | null; + selectedFindingsFilterChips?: Array<{ + label: string; + displayValue?: string; + value: string; + }>; + defaultName?: string; + onSubmit: (values: AlertFormValues) => Promise; + }) => + open ? ( +
+ + {JSON.stringify(seededCondition)} + + + {(selectedFindingsFilterChips ?? []) + .map((chip) => `${chip.label}:${chip.displayValue ?? chip.value}`) + .join("|")} + + +
+ ) : null, +})); + +import { SeedFromFindingsButton } from "../seed-from-findings-button"; + +describe("SeedFromFindingsButton", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should explain why creating an alert is disabled when no real filters are applied", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + const button = screen.getByRole("button", { + name: /Create Alert/i, + }); + const tooltipTrigger = button.parentElement; + expect(tooltipTrigger).not.toBeNull(); + await user.hover(tooltipTrigger as HTMLElement); + + // Then + expect(button).toBeDisabled(); + expect( + await screen.findAllByText(/at least one findings filter/i), + ).not.toHaveLength(0); + }); + + it("should enable creation from the first real filter, including unsupported backend filters", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("button", { name: /Create Alert/i }), + ).not.toBeDisabled(); + expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveClass( + "h-10", + ); + }); + + it("should add all severities when Findings only has non-portable default filters", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { + severity: ["critical", "high", "medium", "low", "informational"], + }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + const filterBag = { + "filter[status__in]": "FAIL", + "filter[muted]": "false", + "filter[scan__in]": "11111111-1111-1111-1111-111111111111", + }; + render(); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(actionMocks.seedAlertRule).toHaveBeenCalledWith({ + ...filterBag, + "filter[severity__in]": [ + "critical", + "high", + "medium", + "low", + "informational", + ], + }), + ); + expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible(); + expect(screen.getByTestId("seeded-condition")).toHaveTextContent( + "severity", + ); + }); + + it("should seed from the full Findings filter bag before opening the modal", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { severity: ["critical", "high"] }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + const filterBag = { + "filter[status__in]": "FAIL", + "filter[muted]": "false", + "filter[scan__in]": "11111111-1111-1111-1111-111111111111", + "filter[severity__in]": "critical,high", + }; + render(); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(actionMocks.seedAlertRule).toHaveBeenCalledWith(filterBag), + ); + expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible(); + expect(routerMocks.push).not.toHaveBeenCalled(); + expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent( + /severity:\+2/i, + ); + expect(screen.getByTestId("seeded-condition")).toHaveTextContent( + "severity", + ); + expect(screen.getByTestId("selected-filter-chips")).not.toHaveTextContent( + /status/i, + ); + }); + + it("should create the alert through the existing alert action from the modal", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { severity: ["critical"] }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + actionMocks.createAlert.mockResolvedValue({ + data: { + id: "alert-1", + attributes: { name: "Findings filter alert" }, + }, + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + await user.click( + screen.getByRole("button", { name: /submit mock alert/i }), + ); + + // Then + await waitFor(() => + expect(actionMocks.createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Findings filter alert", + trigger: "after_scan", + condition: seededCondition, + recipientEmails: ["security@example.com"], + }), + ), + ); + expect(routerMocks.refresh).toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Alert created", + action: expect.anything(), + }), + ); + }); + + it("should add a toast action to navigate to alerts after creating an alert", async () => { + // Given + const user = userEvent.setup(); + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: { op: "any", filter: { severity: ["critical"] } }, + schema_version: 1, + warnings: [], + }, + }, + }); + actionMocks.createAlert.mockResolvedValue({ + data: { + id: "alert-1", + attributes: { name: "Findings filter alert" }, + }, + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + await user.click( + screen.getByRole("button", { name: /submit mock alert/i }), + ); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + const toastAction = toastMock.mock.calls[0][0].action; + render(toastAction); + expect(screen.getByRole("link", { name: /view alerts/i })).toHaveAttribute( + "href", + "/alerts", + ); + }); + + it("should show a toast and keep the modal closed when seed fails", async () => { + // Given + const user = userEvent.setup(); + actionMocks.seedAlertRule.mockResolvedValue({ + error: "invalid_shape", + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Alert seed failed", + }), + ), + ); + expect( + screen.queryByRole("dialog", { name: /create alert/i }), + ).not.toBeInTheDocument(); + }); + + it("should render disabled as a Cloud-only feature in OSS", () => { + // Given + render( + , + ); + + // When + const button = screen.getByRole("button", { name: /Create Alert/i }); + + // Then + expect(button).toBeDisabled(); + expect(button.className).not.toContain("min-w"); + expect(button).not.toHaveClass("justify-start"); + const pricingLink = screen.getByRole("link", { + name: /available in prowler cloud/i, + }); + expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing"); + expect(pricingLink).toHaveClass("whitespace-nowrap"); + expect(pricingLink).toHaveTextContent("Available in Prowler Cloud"); + expect(pricingLink.closest("button")).toBeNull(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(actionMocks.seedAlertRule).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx new file mode 100644 index 0000000000..e3529d4056 --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { useState } from "react"; + +import { + previewAlertCondition, + seedAlertRule, +} from "@/app/(prowler)/alerts/_actions"; +import { listAlertRecipients } from "@/app/(prowler)/alerts/_actions/recipients"; +import { + ALERT_TRIGGER_KINDS, + type AlertCondition, + type AlertPreviewResponse, + type AlertRecipient, + type AlertRule, + type AlertTriggerKind, +} from "@/app/(prowler)/alerts/_types"; +import type { FilterChip } from "@/components/filters/filter-summary-strip"; +import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip"; +import { FindingsFilterBatchControls } from "@/components/findings/findings-filters"; +import { + Badge, + Button, + Card, + CardContent, + Field, + FieldError, + FieldLabel, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Textarea, +} from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { ScanEntity } from "@/types"; +import type { ProviderProps } from "@/types/providers"; + +import { + getAlertFormDefaults, + getEmptyAlertFormDefaults, + getFindingsFiltersFromAlertCondition, +} from "../_lib/alert-adapter"; +import { alertFormSchema } from "../_lib/alert-form-schema"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "../_types/alert-form"; +import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form"; + +interface AlertFormModalProps { + open: boolean; + defaultFrequency: AlertTriggerKind; + providers?: ProviderProps[]; + completedScanIds?: string[]; + scanDetails?: { [key: string]: ScanEntity }[]; + uniqueRegions?: string[]; + uniqueServices?: string[]; + uniqueResourceTypes?: string[]; + uniqueCategories?: string[]; + uniqueGroups?: string[]; + editingAlert?: AlertRule | null; + seededCondition?: AlertCondition | null; + selectedFindingsFilterChips?: FilterChip[]; + defaultName?: string; + onOpenChange: (open: boolean) => void; + onSubmit: (values: AlertFormValues) => Promise; +} + +interface FormErrors { + name?: string; + recipientEmails?: string; + root?: string; +} + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); + +const getRecipientEmails = (selectedEmails: Set): string[] => + Array.from(selectedEmails); + +const ALERT_FREQUENCY_OPTIONS = [ + { + value: ALERT_TRIGGER_KINDS.AFTER_SCAN, + label: "After each scan", + }, + { + value: ALERT_TRIGGER_KINDS.DAILY, + label: "Daily digest", + }, + { + value: ALERT_TRIGGER_KINDS.BOTH, + label: "After each scan and daily", + }, +] as const; + +const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter."; + +const serializeCondition = (condition: AlertCondition | null): string => + condition ? JSON.stringify(condition) : "none"; + +const getAlertFormModalResetKey = ({ + open, + defaultFrequency, + editingAlert, + seededCondition, +}: Pick< + AlertFormModalProps, + "open" | "defaultFrequency" | "editingAlert" | "seededCondition" +>): string => + [ + open ? "open" : "closed", + editingAlert?.id ?? "create", + editingAlert?.attributes.updated_at ?? "", + defaultFrequency, + serializeCondition(seededCondition ?? null), + ].join("|"); + +const allowInitialDialogFocus = () => undefined; + +const uniqueValues = (values: string[]): string[] => + Array.from(new Set(values)); + +interface PreviewState { + status: "success" | "error"; + data?: AlertPreviewResponse; + error?: string; +} + +const formatPreviewNumber = (value: number): string => + new Intl.NumberFormat("en-US").format(value); + +const getPreviewSeverityLabel = (severity: string): string => + severity.charAt(0).toUpperCase() + severity.slice(1); + +const getPreviewMessage = (data: AlertPreviewResponse): string => { + const totalFindings = data.summary.finding_count_total ?? 0; + if (totalFindings === 0) { + return "These filters did not match any findings for the latest scan."; + } + + const findingLabel = totalFindings === 1 ? "finding" : "findings"; + const topSeverity = data.summary.top_severity; + const severityClause = topSeverity + ? `, including ${getPreviewSeverityLabel(topSeverity)} severity` + : ""; + + return `It found ${formatPreviewNumber(totalFindings)} ${findingLabel}${severityClause}.`; +}; + +const PreviewSummarySkeleton = () => ( + + +
+ + +
+ +
+
+); + +const PreviewSummary = ({ preview }: { preview: PreviewState }) => { + if (preview.status === "error") { + return ( + + +
+ + Test result + + Error +
+

{preview.error}

+
+
+ ); + } + + const data = preview.data; + if (!data) return null; + + return ( + + + + Test result + +

+ {getPreviewMessage(data)} +

+
+
+ ); +}; + +const normalizeFindingsFilterKey = (filterKey: string): string => + filterKey.startsWith("filter[") ? filterKey : `filter[${filterKey}]`; + +interface AlertRecipientsSelectProps { + selectedEmails: Set; + onValuesChange: (emails: string[]) => void; +} + +interface RecipientOption { + email: string; + status?: AlertRecipient["attributes"]["status"]; +} + +const getRecipientStatusLabel = ( + status: AlertRecipient["attributes"]["status"], +): string => status.charAt(0).toUpperCase() + status.slice(1); + +const getRecipientOptions = ( + recipients: AlertRecipient[], + selectedEmails: string[], +): RecipientOption[] => { + const options = new Map(); + + recipients.forEach((recipient) => { + const email = normalizeEmail(recipient.attributes.email); + if (!email) return; + options.set(email, { email, status: recipient.attributes.status }); + }); + + selectedEmails.forEach((email) => { + const normalizedEmail = normalizeEmail(email); + if (!normalizedEmail || options.has(normalizedEmail)) return; + options.set(normalizedEmail, { email: normalizedEmail }); + }); + + return Array.from(options.values()).sort((left, right) => + left.email.localeCompare(right.email), + ); +}; + +const AlertRecipientsSelect = ({ + selectedEmails, + onValuesChange, +}: AlertRecipientsSelectProps) => { + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useMountEffect(() => { + listAlertRecipients({ + "page[size]": "100", + sort: "email", + }).then((result) => { + setLoading(false); + if (result?.error) { + setRecipients([]); + setError(result.error); + return; + } + setRecipients(result.data); + setError(null); + }); + }); + + const selectedValues = Array.from(selectedEmails); + const options = getRecipientOptions(recipients, selectedValues); + + return ( +
+ + + + + + option.email)} + > + Select All + + + {options.map((option) => ( + + {option.email} + {option.status && ( + + {getRecipientStatusLabel(option.status)} + + )} + + ))} + + + {error &&

{error}

} +
+ ); +}; + +export const AlertFormModal = (props: AlertFormModalProps) => { + const resetKey = getAlertFormModalResetKey(props); + + return ; +}; + +const AlertFormModalContent = ({ + open, + defaultFrequency, + providers = [], + completedScanIds = [], + scanDetails = [], + uniqueRegions = [], + uniqueServices = [], + uniqueResourceTypes = [], + uniqueCategories = [], + uniqueGroups = [], + editingAlert = null, + seededCondition = null, + selectedFindingsFilterChips = [], + defaultName = "Findings filter alert", + onOpenChange, + onSubmit, +}: AlertFormModalProps) => { + const defaults = editingAlert + ? getAlertFormDefaults(editingAlert) + : getEmptyAlertFormDefaults(defaultFrequency, seededCondition ?? undefined); + const initialName = editingAlert + ? defaults.name + : defaults.name || defaultName; + + // Local state needed: user edits are buffered until the modal form is submitted. + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(defaults.description); + const [frequency, setFrequency] = useState( + defaults.frequency, + ); + const [pendingFilters, setPendingFilters] = useState< + Record + >( + editingAlert + ? getFindingsFiltersFromAlertCondition(editingAlert.attributes.condition) + : {}, + ); + const [selectedRecipientEmails, setSelectedRecipientEmails] = useState( + () => new Set(defaults.recipientEmails.map(normalizeEmail)), + ); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [preview, setPreview] = useState(null); + + const submitLabel = editingAlert ? "Save" : "Create"; + + const setRecipientEmails = (emails: string[]) => + setSelectedRecipientEmails( + new Set(emails.map(normalizeEmail).filter(Boolean)), + ); + + const setPendingFilter = (filterKey: string, values: string[]) => { + setPendingFilters((current) => ({ + ...current, + [normalizeFindingsFilterKey(filterKey)]: uniqueValues(values), + })); + setPreview(null); + }; + + const getPendingFilterValue = (filterKey: string): string[] => + pendingFilters[normalizeFindingsFilterKey(filterKey)] ?? []; + + const buildCurrentValues = (condition: AlertCondition): AlertFormValues => ({ + name, + description, + method: ALERT_NOTIFICATION_METHODS.EMAIL, + frequency, + condition, + recipientEmails: getRecipientEmails(selectedRecipientEmails), + enabled: defaults.enabled, + }); + + const handlePreview = async () => { + if (!editingAlert) return; + + const seedResult = await seedAlertRule(pendingFilters); + if (seedResult?.error) { + setPreview({ + status: "error", + error: ALERT_SEED_ERROR, + }); + return; + } + + const values = buildCurrentValues(seedResult.data.attributes.condition); + const parsed = alertFormSchema.safeParse(values); + if (!parsed.success) { + setPreview({ + status: "error", + error: "Fix alert fields before running test.", + }); + return; + } + + setPreviewLoading(true); + const result = await previewAlertCondition({ + condition: parsed.data.condition, + }); + setPreviewLoading(false); + + if (result?.error) { + setPreview({ status: "error", error: result.error }); + return; + } + + const previewData = result.data.attributes as AlertPreviewResponse; + if (previewData.evaluation_failed) { + setPreview({ + status: "error", + error: previewData.last_error ?? "Preview evaluation failed.", + }); + return; + } + + setPreview({ status: "success", data: previewData }); + }; + + const handleSubmit = async () => { + const seedResult = editingAlert + ? await seedAlertRule(pendingFilters) + : null; + if (seedResult?.error) { + setErrors({ root: ALERT_SEED_ERROR }); + return; + } + + const values = buildCurrentValues( + seedResult?.data.attributes.condition ?? defaults.condition, + ); + const parsed = alertFormSchema.safeParse(values); + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors; + setErrors({ + name: fieldErrors.name?.[0], + recipientEmails: fieldErrors.recipientEmails?.[0], + }); + return; + } + + setSaving(true); + const result = await onSubmit(parsed.data); + setSaving(false); + if (result.ok) { + setErrors({}); + onOpenChange(false); + return; + } + setErrors({ root: result.error ?? "Could not save alert." }); + }; + + return ( + +
+ + + Name + setName(event.target.value)} + /> + {errors.name && {errors.name}} + + + Description +