import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NODE_KIND, ORG_SETUP_PHASE, ORG_WIZARD_STEP, ORGANIZATION_TYPE, type OrganizationType, } from "@/types/organizations"; import { PROVIDERS_GROUP_KIND, PROVIDERS_ROW_TYPE, ProvidersOrganizationRow, ProvidersTableRow, } from "@/types/providers-table"; import type { ScanConfigurationData } from "@/types/scan-configurations"; import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; const { checkConnectionProviderMock, getScheduleMock, getTasksByIdsMock, pushMock, revalidateProvidersMock, startProviderConnectionChecksMock, } = vi.hoisted(() => ({ checkConnectionProviderMock: vi.fn(), getScheduleMock: vi.fn(), getTasksByIdsMock: vi.fn(), pushMock: vi.fn(), revalidateProvidersMock: vi.fn(), startProviderConnectionChecksMock: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }), })); vi.mock("@/actions/organizations/organizations", () => ({ updateOrganizationName: vi.fn(), })); vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider: checkConnectionProviderMock, revalidateProviders: revalidateProvidersMock, startProviderConnectionChecks: startProviderConnectionChecksMock, })); vi.mock("@/actions/task/tasks", () => ({ getTasksByIds: getTasksByIdsMock, })); vi.mock("@/actions/schedules", () => ({ getSchedule: getScheduleMock, })); vi.mock("../forms/delete-form", () => ({ DeleteForm: () => null, })); vi.mock("../forms/delete-organization-form", () => ({ DeleteOrganizationForm: () => null, })); vi.mock("../forms/edit-name-form", () => ({ EditNameForm: () => null, })); vi.mock("../scan-config/manage-scan-config-modal", () => ({ ManageScanConfigModal: ({ open, currentConfigId, }: { open: boolean; currentConfigId: string | null; }) => open ? (
) : null, })); vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({ EDIT_SCAN_SCHEDULE_STATE: { LOADING: "loading", LOADED: "loaded", ERROR: "error", }, EditScanScheduleModal: ({ open, provider, providers, }: { open: boolean; provider?: { providerId: string }; providers?: { providerId: string }[]; }) => open ? (
Editing schedule for{" "} {providers ? `${providers.length} providers` : provider?.providerId}
) : null, })); vi.mock("@/components/shadcn", async (importOriginal) => ({ ...(await importOriginal>()), useToast: () => ({ toast: vi.fn() }), })); vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection: vi.fn(), })); import { DataTableRowActions } from "./data-table-row-actions"; const createRow = (hasSecret = false) => ({ original: { id: "provider-1", rowType: PROVIDERS_ROW_TYPE.PROVIDER, type: "providers", attributes: { provider: "aws", uid: "111111111111", alias: "AWS App Account", status: "completed", resources: 0, connection: { connected: true, last_checked_at: "2025-02-13T11:17:00Z", }, scanner_args: { only_logs: false, excluded_checks: [], aws_retries_max_attempts: 3, }, inserted_at: "2025-02-13T11:17:00Z", updated_at: "2025-02-13T11:17:00Z", created_by: { object: "user", id: "user-1", }, }, relationships: { secret: { data: hasSecret ? { id: "secret-1", type: "secrets" } : null, }, provider_groups: { meta: { count: 0, }, data: [], }, }, groupNames: [], }, }) as unknown as Row; // A dynamic provider outside the hardcoded configurable set (read-only in the UI). const createDynamicRow = (hasSecret = true) => ({ original: { id: "provider-dyn-1", rowType: PROVIDERS_ROW_TYPE.PROVIDER, type: "providers", attributes: { provider: "template", is_dynamic: true, uid: "template-account-0001", alias: "Template Plug-in", status: "completed", resources: 0, connection: { connected: true, last_checked_at: "2025-02-13T11:17:00Z", }, scanner_args: { only_logs: false, excluded_checks: [], aws_retries_max_attempts: 3, }, inserted_at: "2025-02-13T11:17:00Z", updated_at: "2025-02-13T11:17:00Z", created_by: { object: "user", id: "user-1", }, }, relationships: { secret: { data: hasSecret ? { id: "secret-1", type: "secrets" } : null, }, provider_groups: { meta: { count: 0, }, data: [], }, }, groupNames: [], }, }) as unknown as Row; const createOrgRow = () => ({ original: { id: "org-1", rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, orgType: ORGANIZATION_TYPE.AWS, name: "My AWS Organization", externalId: "o-abc123def4", parentExternalId: null, organizationId: "org-1", providerCount: 3, providerIds: ["provider-child-1", "provider-child-2"], subRows: [ { id: "provider-child-1", rowType: PROVIDERS_ROW_TYPE.PROVIDER, type: "providers", attributes: { provider: "aws", uid: "111", alias: null }, relationships: { secret: { data: { id: "secret-1", type: "secrets" } }, }, }, { id: "provider-child-2", rowType: PROVIDERS_ROW_TYPE.PROVIDER, type: "providers", attributes: { provider: "aws", uid: "222", alias: null }, relationships: { secret: { data: null } }, }, ], }, }) as unknown as Row; const createOuRow = () => ({ original: { id: "ou-1", rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, orgType: ORGANIZATION_TYPE.AWS, kind: NODE_KIND.ORGANIZATIONAL_UNIT, name: "Production OU", externalId: "ou-abc123", parentExternalId: "o-abc123def4", organizationId: "org-1", providerCount: 2, providerIds: ["provider-ou-child-1"], subRows: [ { id: "provider-ou-child-1", rowType: PROVIDERS_ROW_TYPE.PROVIDER, type: "providers", attributes: { provider: "aws", uid: "333", alias: null }, relationships: { secret: { data: { id: "secret-2", type: "secrets" } }, }, }, ], }, }) as unknown as Row; const scanConfig: ScanConfigurationData = { type: "scan-configurations", id: "config-1", attributes: { inserted_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", name: "Strict AWS", configuration: {}, providers: ["provider-1"], }, }; describe("DataTableRowActions", () => { afterEach(() => { vi.unstubAllEnvs(); }); beforeEach(() => { getScheduleMock.mockResolvedValue({ data: { type: "schedules", id: "provider-1", attributes: { scan_hour: null }, relationships: { provider: { data: { type: "providers", id: "provider-1" } }, }, }, }); }); it("renders Add Credentials for provider rows without credentials", async () => { // Given const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); // Advanced schedule editing is gated to Prowler Cloud subscribed accounts. expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); expect(screen.getByText("Add Credentials")).toBeInTheDocument(); expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("Delete Provider")).toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); it("allows rename/delete and operational actions for a dynamic provider but hides credential management", async () => { // Given a dynamic provider outside the configurable set, with the advanced // schedule capability enabled (so Edit Scan Schedule can show). const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then — rename and delete are backend-generic, so they stay available expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); expect(screen.getByText("Delete Provider")).toBeInTheDocument(); // ...operational actions stay available too expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("View Scan Jobs")).toBeInTheDocument(); expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument(); // ...but credential management is hidden (no bespoke wizard for dynamic types) expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { // Given const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("View Scan Jobs")); // Then: navigates with the key the scans filter bar binds to // (provider__in, by id), URL-encoded, so the provider is pre-selected. expect(pushMock).toHaveBeenCalledWith( "/scans?filter%5Bprovider__in%5D=provider-1", ); }); it("links to scan jobs by provider id even when the uid contains unsafe chars", async () => { // Given a GitHub provider whose UID is a URL with unsafe chars. const user = userEvent.setup(); const row = createRow(true); ( row.original as unknown as { attributes: { uid: string } } ).attributes.uid = "https://github.com/prowler-cloud/prowler"; render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("View Scan Jobs")); // Then the link carries the provider id, never the raw uid. expect(pushMock).toHaveBeenCalledWith( "/scans?filter%5Bprovider__in%5D=provider-1", ); }); it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("Edit Scan Schedule")); // Then expect( screen.getByRole("dialog", { name: /edit scan schedule/i }), ).toHaveTextContent("Editing schedule for provider-1"); }); it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); }); it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); }); it("opens scan config management with the precomputed current config id", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("Edit Scan Configuration")); // Then expect(screen.getByTestId("manage-scan-config-modal")).toHaveAttribute( "data-current-config-id", "config-1", ); }); it("shows scan config management as unavailable when scan configs failed to load", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then const item = screen .getByText("Scan Configuration unavailable") .closest("[role='menuitem']"); expect(item).toHaveAttribute("aria-disabled", "true"); }); it("hides Edit Scan Configuration for dynamic providers in Prowler Cloud", async () => { // Given a dynamic provider in a Cloud tenant with scan configs available. vi.stubEnv("UI_CLOUD_ENABLED", "true"); const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then — dynamic providers can't use a Scan Configuration, so the action is // absent entirely, not merely shown as "unavailable". expect( screen.queryByText("Edit Scan Configuration"), ).not.toBeInTheDocument(); expect( screen.queryByText("Scan Configuration unavailable"), ).not.toBeInTheDocument(); }); it("renders Update Credentials for provider rows with credentials", async () => { // Given const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then expect(screen.getByText("Update Credentials")).toBeInTheDocument(); expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); }); it("renders all 4 organization actions for org rows", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); expect(screen.getByText("Edit Organization Name")).toBeInTheDocument(); expect(screen.getByText("Update Credentials")).toBeInTheDocument(); // 1 of 2 child providers has a secret expect(screen.getByText("Test Connections (1)")).toBeInTheDocument(); expect(screen.getByText("Delete Organization")).toBeInTheDocument(); }); it("opens Edit Scan Schedule for AWS organization rows", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); await user.click(screen.getByText("Edit Scan Schedule")); expect( screen.getByRole("dialog", { name: /edit scan schedule/i }), ).toHaveTextContent("Editing schedule for 2 providers"); }); it("renders Delete Organization with destructive styling for org rows", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); const deleteItem = screen.getByText("Delete Organization"); // Destructive items are rendered with error text color const menuItem = deleteItem.closest("[role='menuitem']"); expect(menuItem).toBeInTheDocument(); expect(menuItem).toHaveClass("text-text-error-primary"); }); it("renders only Test Connections and Delete for OU rows", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); expect(screen.getByText("Test Connections (1)")).toBeInTheDocument(); // Node action copy follows the node's kind. expect(screen.getByText("Delete Organizational Unit")).toBeInTheDocument(); }); it("shows selected provider count in Test Connections when org row has active selection", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); // Should show count of selected testable providers (2), not all org children (1) expect(screen.getByText("Test Connections (2)")).toBeInTheDocument(); expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument(); }); it("tests every selected provider in one dispatch, not one call each", async () => { // Given — Next's action queue serializes a per-provider loop, so the batch has // to leave in a single action. const user = userEvent.setup(); const testableProviderIds = ["provider-child-1", "provider-standalone"]; startProviderConnectionChecksMock.mockResolvedValue({ "provider-child-1": { taskId: "task-1" }, "provider-standalone": { taskId: "task-2" }, }); getTasksByIdsMock.mockResolvedValue({ "task-1": { data: { attributes: { state: "completed", result: { connected: true } }, }, }, "task-2": { data: { attributes: { state: "completed", result: { connected: true } }, }, }, }); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("Test Connections (2)")); // Then — one dispatch for the batch, one batched read, one revalidation. await vi.waitFor(() => expect(revalidateProvidersMock).toHaveBeenCalledTimes(1), ); expect(startProviderConnectionChecksMock).toHaveBeenCalledTimes(1); expect(startProviderConnectionChecksMock).toHaveBeenCalledWith( testableProviderIds, ); expect(getTasksByIdsMock).toHaveBeenCalledTimes(1); expect(getTasksByIdsMock).toHaveBeenCalledWith(["task-1", "task-2"]); expect(checkConnectionProviderMock).not.toHaveBeenCalled(); }); it("shows selected provider count in Test Connections when OU row has active selection", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); // Should show count of selected testable providers (2), not all OU children (1) expect(screen.getByText("Test Connections (2)")).toBeInTheDocument(); expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument(); }); it("shows bulk Edit Scan Schedule next to Test Connection for selected rows", async () => { // Given const user = userEvent.setup(); render( , ); // When await user.click(screen.getByRole("button")); // Then expect(screen.getByText("Edit Scan Schedule (3)")).toBeInTheDocument(); expect(screen.getByText("Test Connection (2)")).toBeInTheDocument(); }); it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); expect( screen.queryByText("Edit Organization Name"), ).not.toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); it("opens the shared provider wizard when provider credentials action is selected", async () => { // Given const user = userEvent.setup(); const onOpenProviderWizard = vi.fn(); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("Update Credentials")); // Then expect(onOpenProviderWizard).toHaveBeenCalledWith({ providerId: "provider-1", providerType: "aws", providerUid: "111111111111", providerAlias: "AWS App Account", secretId: "secret-1", mode: "update", }); }); it("opens the shared organization wizard when org credentials action is selected", async () => { // Given const user = userEvent.setup(); const onOpenOrganizationWizard = vi.fn(); render( , ); // When await user.click(screen.getByRole("button")); await user.click(screen.getByText("Update Credentials")); // Then expect(onOpenOrganizationWizard).toHaveBeenCalledWith({ organizationType: ORGANIZATION_TYPE.AWS, organizationId: "org-1", organizationName: "My AWS Organization", externalId: "o-abc123def4", targetStep: ORG_WIZARD_STEP.SETUP, targetPhase: ORG_SETUP_PHASE.ACCESS, intent: "edit-credentials", }); }); it("hides Update Credentials for an organization type without an onboarding flow", async () => { // Given: an organization type the wizard cannot onboard (display-only). // Every `ORGANIZATION_TYPE` is onboardable now, so the value comes from // outside the enum. const user = userEvent.setup(); const row = createOrgRow(); (row.original as ProvidersOrganizationRow).orgType = "oraclecloud" as OrganizationType; render( , ); // When await user.click(screen.getByRole("button")); // Then: the name edit stays (a plain PATCH), the wizard re-entry is gone. expect(screen.getByText("Edit Organization Name")).toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); });