mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76199ccff3 | ||
|
|
1b7f20fc5b | ||
|
|
79e595f36e | ||
|
|
1c7c5ca5e9 | ||
|
|
848e9a7fa9 |
@@ -0,0 +1 @@
|
||||
Provider wizard modal state no longer loops or closes before the launch step
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDER_WIZARD_STEP,
|
||||
} from "@/types/provider-wizard";
|
||||
|
||||
import { WIZARD_FOOTER_ACTION_TYPE } from "../steps/footer-controls";
|
||||
import type { ProviderWizardInitialData } from "../types";
|
||||
import { useProviderWizardController } from "./use-provider-wizard-controller";
|
||||
|
||||
@@ -237,7 +238,7 @@ describe("useProviderWizardController", () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the wizard after a successful connection test in update mode", async () => {
|
||||
it("moves to launch step after a successful connection test in update mode", async () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
@@ -265,10 +266,83 @@ describe("useProviderWizardController", () => {
|
||||
result.current.handleTestSuccess();
|
||||
});
|
||||
|
||||
// Credential rotation skips the launch/schedule step.
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the footer setter stable and uses replacement action callbacks", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const firstOnBack = vi.fn();
|
||||
const firstOnSecondaryAction = vi.fn();
|
||||
const firstOnAction = vi.fn();
|
||||
const latestOnBack = vi.fn();
|
||||
const latestOnSecondaryAction = vi.fn();
|
||||
const latestOnAction = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
const initialSetFooterConfig = result.current.setFooterConfig;
|
||||
|
||||
const firstFooterConfig = {
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack: firstOnBack,
|
||||
showSecondaryAction: true,
|
||||
secondaryActionLabel: "Cancel",
|
||||
secondaryActionVariant: "outline" as const,
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onSecondaryAction: firstOnSecondaryAction,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: firstOnAction,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setFooterConfig(firstFooterConfig);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setFooterConfig({
|
||||
...firstFooterConfig,
|
||||
onBack: latestOnBack,
|
||||
onSecondaryAction: latestOnSecondaryAction,
|
||||
onAction: latestOnAction,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.resolvedFooterConfig.onBack?.();
|
||||
result.current.resolvedFooterConfig.onSecondaryAction?.();
|
||||
result.current.resolvedFooterConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.setFooterConfig).toBe(initialSetFooterConfig);
|
||||
expect(result.current.resolvedFooterConfig).toMatchObject({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
showSecondaryAction: true,
|
||||
secondaryActionLabel: "Cancel",
|
||||
secondaryActionVariant: "outline",
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
});
|
||||
expect(firstOnBack).not.toHaveBeenCalled();
|
||||
expect(firstOnSecondaryAction).not.toHaveBeenCalled();
|
||||
expect(firstOnAction).not.toHaveBeenCalled();
|
||||
expect(latestOnBack).toHaveBeenCalledTimes(1);
|
||||
expect(latestOnSecondaryAction).toHaveBeenCalledTimes(1);
|
||||
expect(latestOnAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not override launch footer config in the controller", () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useProviderWizardController({
|
||||
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
);
|
||||
const [footerConfig, setFooterConfig] =
|
||||
const [resolvedFooterConfig, setFooterConfig] =
|
||||
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
|
||||
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
|
||||
null,
|
||||
@@ -220,12 +220,6 @@ export function useProviderWizardController({
|
||||
};
|
||||
|
||||
const handleTestSuccess = () => {
|
||||
if (
|
||||
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
};
|
||||
|
||||
@@ -254,13 +248,11 @@ export function useProviderWizardController({
|
||||
const docsLink = isProviderFlow
|
||||
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
|
||||
: DOCS_URLS.AWS_ORGANIZATIONS;
|
||||
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
|
||||
const modalTitle = getProviderWizardModalTitle(mode);
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
docsLink,
|
||||
footerConfig,
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import { ProviderWizardModal } from "./provider-wizard-modal";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-scroll-hint", () => ({
|
||||
useScrollHint: () => ({
|
||||
containerRef: vi.fn(),
|
||||
sentinelRef: vi.fn(),
|
||||
showScrollHint: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/connect-step", () => ({
|
||||
ConnectStep: () => <div>Connect step</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/credentials-step", () => ({
|
||||
CredentialsStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<div>
|
||||
<div>Credentials step</div>
|
||||
<button type="button" onClick={onNext}>
|
||||
Continue to validate connection
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/test-connection-step", () => ({
|
||||
TestConnectionStep: ({ onSuccess }: { onSuccess: () => void }) => (
|
||||
<div>
|
||||
<div>Test connection step</div>
|
||||
<button type="button" onClick={onSuccess}>
|
||||
Check connection
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/launch-step", () => ({
|
||||
LaunchStep: () => <div>Launch step</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-setup-form", () => ({
|
||||
OrgSetupForm: () => <div>Organization setup</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-account-selection", () => ({
|
||||
OrgAccountSelection: () => <div>Organization account selection</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-launch-scan", () => ({
|
||||
OrgLaunchScan: () => <div>Organization launch scan</div>,
|
||||
}));
|
||||
|
||||
describe("ProviderWizardModal", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
useProviderWizardStore.getState().reset();
|
||||
useOrgSetupStore.getState().reset();
|
||||
});
|
||||
|
||||
it("provides an accessible dialog description without requiring visible helper text", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
// When
|
||||
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: /adding a provider/i });
|
||||
|
||||
expect(dialog).toHaveAccessibleDescription(/connect or update a provider/i);
|
||||
});
|
||||
|
||||
it("shows the launch progress step when update mode reaches launch", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderWizardModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialData={{
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerUid: "111111111111",
|
||||
providerAlias: "production",
|
||||
secretId: "secret-1",
|
||||
mode: PROVIDER_WIZARD_MODE.UPDATE,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Credentials step")).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /continue to validate connection/i }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /check connection/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Launch step")).toBeVisible();
|
||||
expect(screen.getByText("Launch Scan")).toBeVisible();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,12 @@ import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
|
||||
|
||||
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
|
||||
0,
|
||||
PROVIDER_WIZARD_STEP.LAUNCH,
|
||||
PROVIDER_WIZARD_STEP.LAUNCH + 1,
|
||||
);
|
||||
|
||||
const PROVIDER_WIZARD_MODAL_DESCRIPTION =
|
||||
"Connect or update a provider by adding account details, credentials, testing the connection, and launching a scan.";
|
||||
|
||||
interface ProviderWizardModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -100,6 +103,7 @@ export function ProviderWizardModal({
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
description={PROVIDER_WIZARD_MODAL_DESCRIPTION}
|
||||
size="4xl"
|
||||
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
|
||||
import { ConnectStep } from "./connect-step";
|
||||
|
||||
type ConnectStepUiState = {
|
||||
showBack: boolean;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type CapturedConnectAccountFormProps = {
|
||||
onUiStateChange?: (state: ConnectStepUiState) => void;
|
||||
};
|
||||
|
||||
const { capturedConnectAccountFormProps } = vi.hoisted(() => ({
|
||||
capturedConnectAccountFormProps: {
|
||||
current: null as CapturedConnectAccountFormProps | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/workflow/forms", () => ({
|
||||
ConnectAccountForm: (props: CapturedConnectAccountFormProps) => {
|
||||
capturedConnectAccountFormProps.current = props;
|
||||
|
||||
return <div data-testid="connect-account-form" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ConnectStep", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
capturedConnectAccountFormProps.current = null;
|
||||
useProviderWizardStore.getState().reset();
|
||||
});
|
||||
|
||||
it("does not republish footer config for repeated unchanged form UI state", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConnectStep
|
||||
onNext={vi.fn()}
|
||||
onSelectOrganizations={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
onProviderTypeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
const unchangedUiState = {
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.(
|
||||
unchangedUiState,
|
||||
);
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.({
|
||||
...unchangedUiState,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(onFooterChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("publishes a new footer config when form UI state changes", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConnectStep
|
||||
onNext={vi.fn()}
|
||||
onSelectOrganizations={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
onProviderTypeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.({
|
||||
showBack: true,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(2));
|
||||
expect(onFooterChange.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
showBack: true,
|
||||
showAction: true,
|
||||
actionDisabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,35 @@ import {
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
type ConnectStepUiState = {
|
||||
showBack: boolean;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const CONNECT_STEP_INITIAL_UI_STATE: ConnectStepUiState = {
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
function isSameConnectStepUiState(
|
||||
current: ConnectStepUiState,
|
||||
next: ConnectStepUiState,
|
||||
) {
|
||||
return (
|
||||
current.showBack === next.showBack &&
|
||||
current.showAction === next.showAction &&
|
||||
current.actionLabel === next.actionLabel &&
|
||||
current.actionDisabled === next.actionDisabled &&
|
||||
current.isLoading === next.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectStepProps {
|
||||
onNext: () => void;
|
||||
onSelectOrganizations: () => void;
|
||||
@@ -31,13 +60,7 @@ export function ConnectStep({
|
||||
const { setProvider, setVia, setSecretId, setMode } =
|
||||
useProviderWizardStore();
|
||||
const backHandlerRef = useRef<(() => void) | null>(null);
|
||||
const [uiState, setUiState] = useState({
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
});
|
||||
const [uiState, setUiState] = useState(CONNECT_STEP_INITIAL_UI_STATE);
|
||||
|
||||
const formId = "provider-wizard-connect-form";
|
||||
|
||||
@@ -54,6 +77,16 @@ export function ConnectStep({
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleUiStateChange = (nextUiState: ConnectStepUiState) => {
|
||||
setUiState((currentUiState) => {
|
||||
if (isSameConnectStepUiState(currentUiState, nextUiState)) {
|
||||
return currentUiState;
|
||||
}
|
||||
|
||||
return nextUiState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: uiState.showBack,
|
||||
@@ -75,7 +108,7 @@ export function ConnectStep({
|
||||
onSuccess={handleSuccess}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
onProviderTypeChange={onProviderTypeChange}
|
||||
onUiStateChange={setUiState}
|
||||
onUiStateChange={handleUiStateChange}
|
||||
onBackHandlerChange={(handler) => {
|
||||
backHandlerRef.current = handler;
|
||||
}}
|
||||
|
||||
@@ -79,6 +79,11 @@ export const Modal = ({
|
||||
)}
|
||||
</DialogHeader>
|
||||
)}
|
||||
{!title && description && (
|
||||
<DialogDescription className="sr-only">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user