mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
fix(ui): stabilize provider wizard modal and DataTable rendering (#10194)
This commit is contained in:
15
ui/app/(prowler)/providers/page.test.ts
Normal file
15
ui/app/(prowler)/providers/page.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("providers page", () => {
|
||||
it("does not use unstable Date.now keys for the providers DataTable", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
expect(source).not.toContain("key={`providers-${Date.now()}`}");
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,6 @@ const ProvidersTable = async ({
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12">
|
||||
<DataTable
|
||||
key={`providers-${Date.now()}`}
|
||||
columns={ColumnProviders}
|
||||
data={enrichedProviders || []}
|
||||
metadata={providersData?.meta}
|
||||
|
||||
@@ -11,16 +11,6 @@ import {
|
||||
|
||||
import { useProviderWizardController } from "./use-provider-wizard-controller";
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({
|
||||
data: null,
|
||||
@@ -30,9 +20,9 @@ vi.mock("next-auth/react", () => ({
|
||||
|
||||
describe("useProviderWizardController", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
pushMock.mockReset();
|
||||
useProviderWizardStore.getState().reset();
|
||||
useOrgSetupStore.getState().reset();
|
||||
});
|
||||
@@ -131,7 +121,7 @@ describe("useProviderWizardController", () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes and navigates when launch footer action is triggered", () => {
|
||||
it("does not override launch footer config in the controller", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
@@ -146,15 +136,10 @@ describe("useProviderWizardController", () => {
|
||||
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
});
|
||||
|
||||
const { resolvedFooterConfig } = result.current;
|
||||
act(() => {
|
||||
resolvedFooterConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith("/scans");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
expect(result.current.resolvedFooterConfig.showAction).toBe(false);
|
||||
expect(result.current.resolvedFooterConfig.showBack).toBe(false);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset organizations step when org store updates while modal is open", () => {
|
||||
@@ -188,4 +173,69 @@ describe("useProviderWizardController", () => {
|
||||
expect(result.current.wizardVariant).toBe("organizations");
|
||||
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
|
||||
});
|
||||
|
||||
it("does not rehydrate wizard state when initial data changes while modal remains open", async () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
open,
|
||||
initialData,
|
||||
}: {
|
||||
open: boolean;
|
||||
initialData?: {
|
||||
providerId: string;
|
||||
providerType: "gcp";
|
||||
providerUid: string;
|
||||
providerAlias: string;
|
||||
secretId: string | null;
|
||||
mode: "add" | "update";
|
||||
};
|
||||
}) =>
|
||||
useProviderWizardController({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
open: true,
|
||||
initialData: {
|
||||
providerId: "provider-1",
|
||||
providerType: "gcp",
|
||||
providerUid: "project-123",
|
||||
providerAlias: "gcp-main",
|
||||
secretId: null,
|
||||
mode: PROVIDER_WIZARD_MODE.ADD,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useProviderWizardStore.getState().setVia("service-account");
|
||||
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
|
||||
});
|
||||
|
||||
// When: provider data refreshes while modal is still open
|
||||
rerender({
|
||||
open: true,
|
||||
initialData: {
|
||||
providerId: "provider-1",
|
||||
providerType: "gcp",
|
||||
providerUid: "project-123",
|
||||
providerAlias: "gcp-main",
|
||||
secretId: "secret-1",
|
||||
mode: PROVIDER_WIZARD_MODE.UPDATE,
|
||||
},
|
||||
});
|
||||
|
||||
// Then: keep user progress in the current flow
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.TEST);
|
||||
expect(useProviderWizardStore.getState().via).toBe("service-account");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
@@ -63,7 +62,7 @@ export function useProviderWizardController({
|
||||
const initialSecretId = initialData?.secretId ?? null;
|
||||
const initialVia = initialData?.via ?? null;
|
||||
const initialMode = initialData?.mode ?? null;
|
||||
const router = useRouter();
|
||||
const hasHydratedForCurrentOpenRef = useRef(false);
|
||||
const [wizardVariant, setWizardVariant] = useState<WizardVariant>(
|
||||
WIZARD_VARIANT.PROVIDER,
|
||||
);
|
||||
@@ -95,9 +94,15 @@ export function useProviderWizardController({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
hasHydratedForCurrentOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedForCurrentOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
hasHydratedForCurrentOpenRef.current = true;
|
||||
|
||||
if (initialProviderId && initialProviderType && initialProviderUid) {
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setProvider({
|
||||
@@ -198,25 +203,7 @@ export function useProviderWizardController({
|
||||
const docsLink = isProviderFlow
|
||||
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
|
||||
: DOCS_URLS.AWS_ORGANIZATIONS;
|
||||
const resolvedFooterConfig: WizardFooterConfig =
|
||||
isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH
|
||||
? {
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST),
|
||||
showSecondaryAction: false,
|
||||
secondaryActionLabel: "",
|
||||
secondaryActionVariant: "outline",
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
showAction: true,
|
||||
actionLabel: "Go to scans",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: () => {
|
||||
handleClose();
|
||||
router.push("/scans");
|
||||
},
|
||||
}
|
||||
: footerConfig;
|
||||
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
|
||||
const modalTitle = getProviderWizardModalTitle(mode);
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,10 @@ import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
|
||||
|
||||
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
|
||||
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils";
|
||||
import {
|
||||
getOrganizationsStepperOffset,
|
||||
getProviderWizardDocsDestination,
|
||||
} from "./provider-wizard-modal.utils";
|
||||
import { ConnectStep } from "./steps/connect-step";
|
||||
import { CredentialsStep } from "./steps/credentials-step";
|
||||
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
|
||||
@@ -62,6 +65,7 @@ export function ProviderWizardModal({
|
||||
enabled: open,
|
||||
refreshToken: scrollHintRefreshToken,
|
||||
});
|
||||
const docsDestination = getProviderWizardDocsDestination(docsLink);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -80,7 +84,7 @@ export function ProviderWizardModal({
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<a href={docsLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Prowler Docs</span>
|
||||
<span>{`Prowler Docs (${docsDestination})`}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -136,7 +140,11 @@ export function ProviderWizardModal({
|
||||
)}
|
||||
|
||||
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
|
||||
<LaunchStep />
|
||||
<LaunchStep
|
||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
||||
onClose={handleClose}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import {
|
||||
getOrganizationsStepperOffset,
|
||||
getProviderWizardDocsDestination,
|
||||
getProviderWizardModalTitle,
|
||||
} from "./provider-wizard-modal.utils";
|
||||
|
||||
@@ -50,3 +51,21 @@ describe("getProviderWizardModalTitle", () => {
|
||||
expect(title).toBe("Update Provider Credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderWizardDocsDestination", () => {
|
||||
it("returns a compact provider label for short provider docs links", () => {
|
||||
const destination = getProviderWizardDocsDestination(
|
||||
"https://goto.prowler.com/provider-aws",
|
||||
);
|
||||
|
||||
expect(destination).toBe("aws");
|
||||
});
|
||||
|
||||
it("returns a compact destination label for long docs links", () => {
|
||||
const destination = getProviderWizardDocsDestination(
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
);
|
||||
|
||||
expect(destination).toBe("aws-organizations");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,3 +27,21 @@ export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
|
||||
|
||||
return "Adding A Cloud Provider";
|
||||
}
|
||||
|
||||
export function getProviderWizardDocsDestination(docsLink: string) {
|
||||
try {
|
||||
const parsed = new URL(docsLink);
|
||||
const pathSegments = parsed.pathname
|
||||
.split("/")
|
||||
.filter((segment) => segment.length > 0);
|
||||
const lastSegment = pathSegments.at(-1);
|
||||
|
||||
if (!lastSegment) {
|
||||
return parsed.hostname;
|
||||
}
|
||||
|
||||
return lastSegment.replace(/^provider-/, "").replace(/^prowler-cloud-/, "");
|
||||
} catch {
|
||||
return docsLink;
|
||||
}
|
||||
}
|
||||
|
||||
85
ui/components/providers/wizard/steps/launch-step.test.tsx
Normal file
85
ui/components/providers/wizard/steps/launch-step.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
|
||||
import { LaunchStep } from "./launch-step";
|
||||
|
||||
const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({
|
||||
scheduleDailyMock: vi.fn(),
|
||||
scanOnDemandMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
scheduleDaily: scheduleDailyMock,
|
||||
scanOnDemand: scanOnDemandMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
useToast: () => ({
|
||||
toast: toastMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LaunchStep", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
scheduleDailyMock.mockReset();
|
||||
scanOnDemandMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
useProviderWizardStore.getState().reset();
|
||||
});
|
||||
|
||||
it("launches a daily scan and shows toast", async () => {
|
||||
// Given
|
||||
const onClose = vi.fn();
|
||||
const onFooterChange = vi.fn();
|
||||
useProviderWizardStore.setState({
|
||||
providerId: "provider-1",
|
||||
providerType: "gcp",
|
||||
providerUid: "project-123",
|
||||
mode: "add",
|
||||
});
|
||||
|
||||
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
|
||||
render(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={onClose}
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// When
|
||||
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
|
||||
await act(async () => {
|
||||
initialFooterConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
|
||||
expect(sentFormData.get("providerId")).toBe("provider-1");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Scan Launched",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { TREE_ITEM_STATUS } from "@/types/tree";
|
||||
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
const SCAN_SCHEDULE = {
|
||||
DAILY: "daily",
|
||||
SINGLE: "single",
|
||||
} as const;
|
||||
|
||||
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
|
||||
|
||||
interface LaunchStepProps {
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
}
|
||||
|
||||
export function LaunchStep({
|
||||
onBack,
|
||||
onClose,
|
||||
onFooterChange,
|
||||
}: LaunchStepProps) {
|
||||
const { toast } = useToast();
|
||||
const { providerId } = useProviderWizardStore();
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
|
||||
SCAN_SCHEDULE.DAILY,
|
||||
);
|
||||
const launchActionRef = useRef<() => void>(() => {});
|
||||
|
||||
const handleLaunchScan = async () => {
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLaunching(true);
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
const result =
|
||||
scheduleOption === SCAN_SCHEDULE.DAILY
|
||||
? await scheduleDaily(formData)
|
||||
: await scanOnDemand(formData);
|
||||
|
||||
if (result?.error) {
|
||||
setIsLaunching(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to launch scan",
|
||||
description: String(result.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLaunching(false);
|
||||
onClose();
|
||||
toast({
|
||||
title: "Scan Launched",
|
||||
description:
|
||||
scheduleOption === SCAN_SCHEDULE.DAILY
|
||||
? "Daily scan scheduled successfully."
|
||||
: "Single scan launched successfully.",
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href="/scans">Go to scans</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
launchActionRef.current = () => {
|
||||
void handleLaunchScan();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
backDisabled: isLaunching,
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
|
||||
actionDisabled: isLaunching || !providerId,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: () => {
|
||||
launchActionRef.current();
|
||||
},
|
||||
});
|
||||
}, [isLaunching, onBack, onFooterChange, providerId]);
|
||||
|
||||
if (isLaunching) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<TreeSpinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaunchStep() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
|
||||
<CheckCircle2 className="text-success size-12" />
|
||||
<h3 className="text-xl font-semibold">Provider connected successfully</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Continue with the action button to go to scans.
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
|
||||
<h3 className="text-sm font-semibold">Connection validated!</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Choose how you want to launch scans for this provider.
|
||||
</p>
|
||||
|
||||
{!providerId && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
Provider data is missing. Go back and test the connection again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
|
||||
<Select
|
||||
value={scheduleOption}
|
||||
onValueChange={(value) =>
|
||||
setScheduleOption(value as ScanScheduleOption)
|
||||
}
|
||||
disabled={isLaunching || !providerId}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-[376px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SCAN_SCHEDULE.DAILY}>
|
||||
Scan Daily (every 24 hours)
|
||||
</SelectItem>
|
||||
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
|
||||
Run a single scan (no recurring schedule)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
@@ -6,8 +7,9 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import { TestConnectionStep } from "./test-connection-step";
|
||||
|
||||
const { getProviderMock } = vi.hoisted(() => ({
|
||||
const { getProviderMock, loadingFromFormMock } = vi.hoisted(() => ({
|
||||
getProviderMock: vi.fn(),
|
||||
loadingFromFormMock: { current: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => ({
|
||||
@@ -15,7 +17,19 @@ vi.mock("@/actions/providers", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../workflow/forms/test-connection-form", () => ({
|
||||
TestConnectionForm: () => <div data-testid="test-connection-form" />,
|
||||
TestConnectionForm: ({
|
||||
onLoadingChange,
|
||||
}: {
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (loadingFromFormMock.current) {
|
||||
onLoadingChange?.(true);
|
||||
}
|
||||
}, [onLoadingChange]);
|
||||
|
||||
return <div data-testid="test-connection-form" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TestConnectionStep", () => {
|
||||
@@ -23,6 +37,7 @@ describe("TestConnectionStep", () => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
getProviderMock.mockReset();
|
||||
loadingFromFormMock.current = false;
|
||||
useProviderWizardStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -64,4 +79,54 @@ describe("TestConnectionStep", () => {
|
||||
});
|
||||
expect(useProviderWizardStore.getState().secretId).toBe("secret-1");
|
||||
});
|
||||
|
||||
it("updates footer action label to checking while connection test is in progress", async () => {
|
||||
// Given
|
||||
loadingFromFormMock.current = true;
|
||||
useProviderWizardStore.setState({
|
||||
providerId: "provider-1",
|
||||
providerType: "gcp",
|
||||
mode: PROVIDER_WIZARD_MODE.ADD,
|
||||
});
|
||||
getProviderMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "provider-1",
|
||||
attributes: {
|
||||
uid: "project-123",
|
||||
provider: "gcp",
|
||||
alias: "Main",
|
||||
connection: { connected: false, last_checked_at: null },
|
||||
scanner_args: {},
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { type: "provider-secrets", id: "secret-1" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
// When
|
||||
render(
|
||||
<TestConnectionStep
|
||||
onSuccess={vi.fn()}
|
||||
onResetCredentials={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(onFooterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const footerConfigs = onFooterChange.mock.calls.map((call) => call[0]);
|
||||
const hasCheckingState = footerConfigs.some(
|
||||
(config) =>
|
||||
config.actionLabel === "Checking connection..." &&
|
||||
config.actionDisabled === true,
|
||||
);
|
||||
expect(hasCheckingState).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,10 +92,9 @@ export function TestConnectionStep({
|
||||
backDisabled: isFormLoading,
|
||||
onBack: onResetCredentials,
|
||||
showAction: canSubmit,
|
||||
actionLabel:
|
||||
mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
? "Check connection"
|
||||
: "Launch scan",
|
||||
actionLabel: isFormLoading
|
||||
? "Checking connection..."
|
||||
: "Check connection",
|
||||
actionDisabled: isFormLoading,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -14,9 +13,8 @@ import {
|
||||
checkConnectionProvider,
|
||||
deleteCredentials,
|
||||
} from "@/actions/providers";
|
||||
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
|
||||
import { getTask } from "@/actions/task/tasks";
|
||||
import { CheckIcon, RocketIcon } from "@/components/icons";
|
||||
import { CheckIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { Form } from "@/components/ui/form";
|
||||
@@ -83,7 +81,6 @@ export const TestConnectionForm = ({
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const [isResettingCredentials, setIsResettingCredentials] = useState(false);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const formSchema = testConnectionFormSchema;
|
||||
|
||||
@@ -91,7 +88,6 @@ export const TestConnectionForm = ({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerId,
|
||||
runOnce: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,44 +145,12 @@ export const TestConnectionForm = ({
|
||||
}
|
||||
|
||||
if (connected && !isUpdated) {
|
||||
try {
|
||||
// Check if the runOnce checkbox is checked
|
||||
const runOnce = form.watch("runOnce");
|
||||
|
||||
let data;
|
||||
|
||||
if (runOnce) {
|
||||
data = await scanOnDemand(formData);
|
||||
} else {
|
||||
data = await scheduleDaily(formData);
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
setApiErrorMessage(data.error);
|
||||
form.setError("providerId", {
|
||||
type: "server",
|
||||
message: data.error,
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: data.error,
|
||||
});
|
||||
} else {
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirecting(true);
|
||||
router.push("/scans");
|
||||
}
|
||||
} catch (_error) {
|
||||
form.setError("providerId", {
|
||||
type: "server",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
});
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
return router.push("/providers");
|
||||
} else {
|
||||
setConnectionStatus({
|
||||
connected: false,
|
||||
@@ -235,25 +199,6 @@ export const TestConnectionForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (isRedirecting) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-12">
|
||||
<div className="relative">
|
||||
<div className="bg-primary/20 h-24 w-24 animate-pulse rounded-full" />
|
||||
<div className="border-primary absolute inset-0 h-24 w-24 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-primary text-xl font-medium">
|
||||
Scan initiated successfully
|
||||
</p>
|
||||
<p className="text-small mt-2 font-bold text-gray-500">
|
||||
Redirecting to scans job details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -262,14 +207,10 @@ export const TestConnectionForm = ({
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="mb-2 text-xl font-medium">
|
||||
{!isUpdated
|
||||
? "Check connection and launch scan"
|
||||
: "Check connection"}
|
||||
</div>
|
||||
<div className="mb-2 text-xl font-medium">Check connection</div>
|
||||
<p className="text-small text-default-500 py-2">
|
||||
{!isUpdated
|
||||
? "After a successful connection, a scan will automatically run every 24 hours. To run a single scan instead, select the checkbox below."
|
||||
? "After a successful connection, continue to the launch step to configure and start your scan."
|
||||
: "A successful connection will redirect you to the providers page."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -309,20 +250,6 @@ export const TestConnectionForm = ({
|
||||
providerUID={providerData.data.attributes.uid}
|
||||
/>
|
||||
|
||||
{!isUpdated && !connectionStatus?.error && (
|
||||
<Checkbox
|
||||
{...form.register("runOnce")}
|
||||
isSelected={!!form.watch("runOnce")}
|
||||
classNames={{
|
||||
label: "text-small",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
color="default"
|
||||
>
|
||||
Run a single scan (no recurring schedule).
|
||||
</Checkbox>
|
||||
)}
|
||||
|
||||
{isUpdated && !connectionStatus?.error && (
|
||||
<p className="text-small text-default-500 py-2">
|
||||
Check the new credentials and test the connection.
|
||||
@@ -372,13 +299,13 @@ export const TestConnectionForm = ({
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
!isUpdated && <RocketIcon size={24} />
|
||||
<CheckIcon size={24} />
|
||||
)}
|
||||
{isLoading
|
||||
? "Loading"
|
||||
? "Checking"
|
||||
: isUpdated
|
||||
? "Check connection"
|
||||
: "Launch scan"}
|
||||
: "Continue"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -756,6 +756,9 @@ export class ProvidersPage extends BasePage {
|
||||
});
|
||||
if (await button.isVisible().catch(() => false)) {
|
||||
await button.click();
|
||||
if (actionName === "Check connection") {
|
||||
await this.handleCheckConnectionCompletion();
|
||||
}
|
||||
if (actionName === "Launch scan") {
|
||||
await this.handleLaunchScanCompletion();
|
||||
}
|
||||
@@ -768,9 +771,9 @@ export class ProvidersPage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
private async handleLaunchScanCompletion(): Promise<void> {
|
||||
const goToScansButton = this.page.getByRole("button", {
|
||||
name: "Go to scans",
|
||||
private async handleCheckConnectionCompletion(): Promise<void> {
|
||||
const launchScanButton = this.page.getByRole("button", {
|
||||
name: "Launch scan",
|
||||
exact: true,
|
||||
});
|
||||
const connectionError = this.page.locator(
|
||||
@@ -779,9 +782,9 @@ export class ProvidersPage extends BasePage {
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.page.waitForURL(/\/scans/, { timeout: 20000 }),
|
||||
goToScansButton.waitFor({ state: "visible", timeout: 20000 }),
|
||||
connectionError.waitFor({ state: "visible", timeout: 20000 }),
|
||||
launchScanButton.waitFor({ state: "visible", timeout: 30000 }),
|
||||
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
|
||||
connectionError.waitFor({ state: "visible", timeout: 30000 }),
|
||||
]);
|
||||
} catch {
|
||||
// Continue and inspect visible state below.
|
||||
@@ -794,14 +797,47 @@ export class ProvidersPage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.page.url().includes("/scans")) {
|
||||
return;
|
||||
if (await launchScanButton.isVisible().catch(() => false)) {
|
||||
await launchScanButton.click();
|
||||
await this.handleLaunchScanCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLaunchScanCompletion(): Promise<void> {
|
||||
const connectionError = this.page.locator(
|
||||
"div.border-border-error p.text-text-error-primary",
|
||||
);
|
||||
const launchErrorToast = this.page.getByRole("alert").filter({
|
||||
hasText: /Unable to launch scan/i,
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
|
||||
connectionError.waitFor({ state: "visible", timeout: 30000 }),
|
||||
launchErrorToast.waitFor({ state: "visible", timeout: 30000 }),
|
||||
]);
|
||||
} catch {
|
||||
// Continue and inspect visible state below.
|
||||
}
|
||||
|
||||
if (await goToScansButton.isVisible().catch(() => false)) {
|
||||
await goToScansButton.click();
|
||||
await this.page.waitForURL(/\/scans/, { timeout: 30000 });
|
||||
if (await connectionError.isVisible().catch(() => false)) {
|
||||
const errorText = await connectionError.textContent();
|
||||
throw new Error(
|
||||
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (await launchErrorToast.isVisible().catch(() => false)) {
|
||||
const errorText = await launchErrorToast.textContent();
|
||||
throw new Error(
|
||||
`Launch scan failed with error: ${errorText?.trim() || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(this.wizardModal).not.toBeVisible({ timeout: 30000 });
|
||||
await this.page.waitForURL(/\/providers/, { timeout: 30000 });
|
||||
await expect(this.providersTable).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async selectCredentialsType(type: AWSCredentialType): Promise<void> {
|
||||
|
||||
@@ -38,6 +38,9 @@ export class ScansPage extends BasePage {
|
||||
|
||||
async verifyPageLoaded(): Promise<void> {
|
||||
// Verify the scans page is loaded
|
||||
if (!this.page.url().includes("/scans")) {
|
||||
await this.goto();
|
||||
}
|
||||
|
||||
await expect(this.page).toHaveTitle(/Prowler/);
|
||||
await expect(this.scanTable).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user