diff --git a/ui/changelog.d/imported-provider-provenance.added.md b/ui/changelog.d/imported-provider-provenance.added.md new file mode 100644 index 0000000000..79999a976a --- /dev/null +++ b/ui/changelog.d/imported-provider-provenance.added.md @@ -0,0 +1 @@ +Prowler Cloud indicator for providers created via Import Findings alongside every connection status diff --git a/ui/components/providers/table/column-providers.test.tsx b/ui/components/providers/table/column-providers.test.tsx index 89e7aea791..27e76f3b3b 100644 --- a/ui/components/providers/table/column-providers.test.tsx +++ b/ui/components/providers/table/column-providers.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { PROVIDERS_ROW_TYPE, @@ -10,9 +11,18 @@ import { import { getColumnProviders } from "./column-providers"; -vi.mock("@/components/shadcn", () => ({ - Badge: ({ children }: { children: ReactNode }) => {children}, -})); +vi.mock("@/components/shadcn", async () => { + const tooltip = await vi.importActual< + typeof import("@/components/shadcn/tooltip") + >("@/components/shadcn/tooltip"); + + return { + Badge: ({ children, ...props }: { children: ReactNode }) => ( + {children} + ), + ...tooltip, + }; +}); vi.mock("@/components/shadcn/checkbox/checkbox", () => ({ Checkbox: () => null, @@ -64,6 +74,7 @@ const providerRow: ProvidersProviderRow = { attributes: { provider: "aws", is_dynamic: false, + is_imported: false, uid: "123456789012", alias: "Production", status: "completed", @@ -115,6 +126,33 @@ function renderLastScanCell(row: ProvidersTableRow) { render(<>{element as ReactNode}); } +function renderStatusCell(row: ProvidersTableRow) { + const statusColumn = getColumnProviders( + {}, + [], + [], + [], + vi.fn(), + vi.fn(), + vi.fn(), + ).find((column) => column.id === "status"); + + const cell = statusColumn?.cell; + if (typeof cell !== "function") { + throw new Error("Status column cell renderer not found"); + } + + const element = cell({ + row: { original: row }, + } as unknown as Parameters[0]); + + render(<>{element as ReactNode}); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("getColumnProviders", () => { it("falls back to connection last_checked_at when lastScanAt is undefined", () => { renderLastScanCell({ ...providerRow, lastScanAt: undefined }); @@ -129,4 +167,133 @@ describe("getColumnProviders", () => { expect(screen.getByText("Never")).toBeVisible(); expect(screen.queryByText("2026-01-01T00:00:00Z")).not.toBeInTheDocument(); }); + + it("shows Not connected with the imported tooltip in Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + const importedProvider = { + ...providerRow, + attributes: { + ...providerRow.attributes, + is_imported: true, + connection: { + connected: null, + last_checked_at: null, + }, + }, + relationships: { + ...providerRow.relationships, + secret: { data: null }, + }, + } satisfies ProvidersProviderRow; + + // When + renderStatusCell(importedProvider); + + // Then + expect(screen.getByText("Not connected")).toBeVisible(); + const indicator = screen.getByLabelText("Imported provider"); + expect(indicator).not.toHaveTextContent("Imported"); + expect(indicator).toHaveAttribute("tabindex", "0"); + expect(indicator.querySelector(".lucide-terminal")).toBeInTheDocument(); + expect(indicator.parentElement).toHaveClass("items-stretch"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.queryByText("Disconnected")).not.toBeInTheDocument(); + + await user.hover(indicator); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + /^This provider has findings imported with the Prowler CLI$/, + ); + }); + + it("shows the imported tooltip beside Connected in Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + const connectedImportedProvider = { + ...providerRow, + attributes: { + ...providerRow.attributes, + is_imported: true, + connection: { + connected: true, + last_checked_at: "2026-01-01T00:00:00Z", + }, + }, + } satisfies ProvidersProviderRow; + + // When + renderStatusCell(connectedImportedProvider); + + // Then + expect(screen.getByText("Connected")).toBeVisible(); + const indicator = screen.getByLabelText("Imported provider"); + expect(indicator).toBeVisible(); + expect(indicator.querySelector(".lucide-terminal")).toBeInTheDocument(); + expect(indicator.parentElement).toHaveClass("items-stretch"); + expect(indicator).not.toHaveTextContent("Imported"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + await user.hover(indicator); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + /^This provider has findings imported with the Prowler CLI$/, + ); + }); + + it("keeps Connection failed for imported providers in Cloud", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const importedProviderWithCredentials = { + ...providerRow, + attributes: { + ...providerRow.attributes, + is_imported: true, + connection: { + connected: false, + last_checked_at: null, + }, + }, + } satisfies ProvidersProviderRow; + + // When + renderStatusCell(importedProviderWithCredentials); + + // Then + expect(screen.getByText("Connection failed")).toBeVisible(); + expect(screen.queryByText("Not connected")).not.toBeInTheDocument(); + const indicator = screen.getByLabelText("Imported provider"); + expect(indicator).not.toHaveTextContent("Imported"); + }); + + it("does not expose imported provenance in OSS", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const importedProvider = { + ...providerRow, + attributes: { + ...providerRow.attributes, + is_imported: true, + connection: { + connected: null, + last_checked_at: null, + }, + }, + relationships: { + ...providerRow.relationships, + secret: { data: null }, + }, + } satisfies ProvidersProviderRow; + + // When + renderStatusCell(importedProvider); + + // Then + expect(screen.getByText("Not connected")).toBeVisible(); + expect( + screen.queryByLabelText("Imported provider"), + ).not.toBeInTheDocument(); + }); }); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index c8ee2407e4..78c59ac764 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -1,13 +1,18 @@ "use client"; import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; -import { Building2, FolderTree } from "lucide-react"; +import { Building2, FolderTree, Terminal } from "lucide-react"; import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; -import { Badge } from "@/components/shadcn"; +import { + Badge, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { DateWithTime, EntityInfo } from "@/components/shadcn/entities"; @@ -15,6 +20,7 @@ import { DataTableColumnHeader } from "@/components/shadcn/table"; import { DataTableExpandAllToggle } from "@/components/shadcn/table/data-table-expand-all-toggle"; import { DataTableExpandableCell } from "@/components/shadcn/table/data-table-expandable-cell"; import { getNodeLabel } from "@/lib/organizations"; +import { isCloud } from "@/lib/shared/env"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, @@ -51,28 +57,70 @@ const OrganizationIcon = ({ groupKind }: { groupKind: ProvidersGroupKind }) => { ); }; -const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => { +interface ProviderStatusCellProps { + connected: boolean | null; + isImported: boolean; +} + +const IMPORTED_PROVIDER_TOOLTIP = + "This provider has findings imported with the Prowler CLI"; + +const ImportedIndicator = () => { + const indicator = ( + + + + ); + + return ( + + {indicator} + {IMPORTED_PROVIDER_TOOLTIP} + + ); +}; + +const ProviderStatusCell = ({ + connected, + isImported, +}: ProviderStatusCellProps) => { + let statusBadge; + if (connected === true) { - return ( + statusBadge = ( Connected ); - } - - if (connected === false) { - return ( + } else if (connected === false) { + statusBadge = ( Connection failed ); + } else { + statusBadge = ( + + Not connected + + ); } - return ( - - Not connected - - ); + if (isImported) { + return ( +
+ {statusBadge} + +
+ ); + } + + return statusBadge; }; function getSelectionLabel(row: Row): string | undefined { @@ -299,6 +347,9 @@ export function getColumnProviders( return ( ); }, diff --git a/ui/types/providers.ts b/ui/types/providers.ts index def1f9f004..f6f898ce48 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -80,13 +80,15 @@ export interface ProviderProps { attributes: { provider: ProviderType; is_dynamic: boolean; + /** Import Findings provenance returned only by the Prowler Cloud API. */ + is_imported?: boolean; uid: string; alias: string; status: "completed" | "pending" | "cancelled"; resources: number; connection: { - connected: boolean; - last_checked_at: string; + connected: boolean | null; + last_checked_at: string | null; }; scanner_args: { only_logs: boolean;