From dc67fe4f37fea15e86eb3b3f76916a5109ba94cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 24 Sep 2026 09:57:59 +0200 Subject: [PATCH] feat(ui): connect and test AWS accounts in one step (#12876) Co-authored-by: alejandrobailo --- docs/user-guide/tutorials/prowler-app.mdx | 4 + .../providers/providers-page.harness.tsx | 18 +- .../providers-page.integration.test.tsx | 10 +- .../aws-one-step-connect.changed.md | 2 +- .../hooks/use-provider-wizard-controller.ts | 6 + .../wizard/provider-wizard-modal.test.tsx | 113 +++++++- .../wizard/provider-wizard-modal.tsx | 20 +- .../provider-wizard-modal.utils.test.ts | 118 ++++++-- .../wizard/provider-wizard-modal.utils.ts | 42 ++- .../steps/aws/aws-connect-step.test.tsx | 263 +++++++++++++++++- .../wizard/steps/aws/aws-connect-step.tsx | 163 ++++++++++- .../steps/aws/connect-aws-account.test.ts | 10 +- .../wizard/steps/aws/connect-aws-account.ts | 3 +- .../providers/wizard/steps/connect-step.tsx | 2 +- .../providers/wizard/wizard-stepper.tsx | 7 +- ui/lib/tours/add-provider.tour.ts | 2 +- 16 files changed, 697 insertions(+), 86 deletions(-) diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index ddbd489cdc..db0916e4a4 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -89,6 +89,10 @@ After adding your cloud account credentials, click the `Check connection` button Test Connection + +For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan. + + ## Step 6: Scan Started After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 74d249d719..61a96f64c2 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -54,11 +54,13 @@ export class ProvidersPageHarness extends BrowserHarness { return this.countRequests("POST", "/apply"); } - /** `POST /providers` alone; the substring match would also count secrets. */ + /** `POST /providers` alone: secrets and connection checks nest under it. */ get providerCreateCallCount(): number { - return ( - this.countRequests("POST", "/providers") - this.secretCreateCallCount - ); + return this.requestLog.filter( + (request) => + request.method === "POST" && + new URL(request.url).pathname.replace(/\/$/, "").endsWith("/providers"), + ).length; } get secretCreateCallCount(): number { @@ -94,7 +96,7 @@ export class ProvidersPageHarness extends BrowserHarness { ).length; } - private get connectionCallCount(): number { + get connectionCallCount(): number { return this.countRequests("POST", "/connection"); } @@ -263,9 +265,9 @@ export class ProvidersPageHarness extends BrowserHarness { await this.clickPrimary(/Connect account/); } - /** Wait until the connection test step is showing with its action ready. */ - async waitForConnectionTestStep(): Promise { - await this.waitForButton(/Check connection/, 10000); + /** Wait until the provider wizard reached its launch step. */ + async waitForProviderLaunchStep(timeoutMs = 20000): Promise { + await this.waitForText(/Scan Schedule/, timeoutMs); } /** Switch back to a single account from the organization flow's tabs. */ diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index 3da8bc94f6..5f3fd66bfc 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -213,7 +213,7 @@ describe("Organization onboarding wizard", () => { }); describe("Wizard progress", () => { - it("drops the credentials row once AWS is picked, since one step covers both", async () => { + it("drops the credentials and test rows once AWS is picked, since one step covers them", async () => { const harness = new ProvidersPageHarness(awsOnboardingFixture()); await harness.mount(); expect(harness.stepperLabels()).toEqual([ @@ -228,7 +228,6 @@ describe("Organization onboarding wizard", () => { expect(harness.stepperLabels()).toEqual([ "Link a Provider", - "Validate Connection", "Launch Scan", ]); }, 40000); @@ -237,7 +236,7 @@ describe("Organization onboarding wizard", () => { describe("Single account with access keys", () => { // Runs compiled by the React Compiler, unlike the unit suite: it guards the // form's validity being read as a reactive value, not frozen in a memo. - it("enables Connect account once the form is filled and jumps to the connection test", async () => { + it("enables Connect account once the form is filled and jumps to the launch step", async () => { const harness = new ProvidersPageHarness(awsOnboardingFixture()); await harness.mount(); await harness.selectProviderType(/Amazon Web Services/); @@ -251,9 +250,12 @@ describe("Organization onboarding wizard", () => { await harness.connectAccount(); - await harness.waitForConnectionTestStep(); + await harness.waitForProviderLaunchStep(); expect(harness.providerCreateCallCount).toBe(1); expect(harness.secretCreateCallCount).toBe(1); + // Reaching launch above is what proves no separate test step ran; this + // pins that the one step really did check the connection. + expect(harness.connectionCallCount).toBe(1); const secret = await harness.lastRequestBody<{ data: { relationships: { provider: { data: { id: string } } } }; }>("POST", "/providers/secrets"); diff --git a/ui/changelog.d/aws-one-step-connect.changed.md b/ui/changelog.d/aws-one-step-connect.changed.md index 8a20db1c83..924f32b1bb 100644 --- a/ui/changelog.d/aws-one-step-connect.changed.md +++ b/ui/changelog.d/aws-one-step-connect.changed.md @@ -1 +1 @@ -AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored with the account before the connection test +AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index d73b6ce414..ee78b91ed9 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -215,6 +215,11 @@ export function useProviderWizardController({ ]); const isOrgDirectEntry = Boolean(orgInitialData); + // Opened on an existing account's credentials, so the one-step AWS flow is not + // in play. Same three fields the hydration above requires to start on CREDENTIALS. + const isDirectCredentialsEntry = Boolean( + initialProviderId && initialProviderType && initialProviderUid, + ); const handleClose = () => { // Closing the wizard at any point ends the add-provider tour; the checkpoint @@ -323,6 +328,7 @@ export function useProviderWizardController({ handleClose, handleDialogOpenChange, handleTestSuccess, + isDirectCredentialsEntry, isOrgDirectEntry, isProviderFlow, mode, diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index 5cf6d71e89..8ed98ce18d 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -19,11 +19,17 @@ const { addProvider, addRegistryProvider, getInstalledRegistryProviderOptions, + testProviderConnection, + updateCredentialsProvider, + updateProvider, } = vi.hoisted(() => ({ addCredentialsProvider: vi.fn(), addProvider: vi.fn(), addRegistryProvider: vi.fn(), getInstalledRegistryProviderOptions: vi.fn(), + testProviderConnection: vi.fn(), + updateCredentialsProvider: vi.fn(), + updateProvider: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -38,7 +44,11 @@ vi.mock("next-auth/react", () => ({ vi.mock("@/actions/providers/providers", () => ({ addCredentialsProvider, addProvider, + updateCredentialsProvider, + updateProvider, })); +// The real module reaches next-auth through lib/helper -> auth.config. +vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection })); vi.mock("@/actions/providers/registry-provider", () => ({ addRegistryProvider, })); @@ -57,7 +67,14 @@ vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour: vi.fn(), })); vi.mock("./steps/credentials-step", () => ({ - CredentialsStep: () =>

Credential details

, + CredentialsStep: ({ onBack }: { onBack: () => void }) => ( + <> +

Credential details

+ + + ), })); vi.mock("./steps/test-connection-step", () => ({ TestConnectionStep: ({ @@ -73,7 +90,16 @@ vi.mock("./steps/test-connection-step", () => ({ ), })); -vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null })); +vi.mock("./steps/launch-step", () => ({ + LaunchStep: ({ onBack }: { onBack: () => void }) => ( + <> +

Launch scan

+ + + ), +})); vi.mock("../organizations/azure-org-setup-form", () => ({ AzureOrgSetupForm: () => null, })); @@ -128,6 +154,9 @@ describe("provider wizard account creation", () => { status: "ready", options: [{ type: "acme", label: "Acme Cloud" }], }); + testProviderConnection.mockResolvedValue({ connected: true, error: null }); + updateCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + updateProvider.mockResolvedValue({ data: { id: "provider-1" } }); }); afterEach(() => { @@ -305,9 +334,11 @@ describe("provider wizard account creation", () => { await waitFor(() => expect(connect).toBeEnabled()); await user.click(connect); - // Then: the separate credentials step never shows up. - expect(await screen.findByText("Connection test")).toBeVisible(); + // Then: neither the credentials step nor the connection test shows up. + expect(await screen.findByText("Launch scan")).toBeVisible(); expect(screen.queryByText("Credential details")).not.toBeInTheDocument(); + expect(screen.queryByText("Connection test")).not.toBeInTheDocument(); + expect(testProviderConnection).toHaveBeenCalledWith("provider-1"); expect(useProviderWizardStore.getState()).toMatchObject({ providerId: "provider-1", secretId: "secret-1", @@ -315,7 +346,71 @@ describe("provider wizard account creation", () => { }); }); - it("returns to the one-step form when the connection test is stepped back from", async () => { + it("keeps the account on the one-step form when the connection is refused", async () => { + // Given + addProvider.mockResolvedValue({ data: { id: "provider-1" } }); + addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + testProviderConnection.mockResolvedValue({ + connected: false, + error: "The role could not be assumed.", + }); + const user = await pickAws(); + + // When + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + const connect = screen.getByRole("button", { name: "Connect account" }); + await waitFor(() => expect(connect).toBeEnabled()); + await user.click(connect); + + // Then + expect( + await screen.findByText("The role could not be assumed."), + ).toBeVisible(); + expect(screen.queryByText("Launch scan")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /Role ARN/ })).toBeVisible(); + }); + + it("closes instead of launching a scan when AWS credentials are updated", async () => { + // Given: the row action opens an existing AWS provider's credentials. + addProvider.mockResolvedValue({ data: { id: "provider-1" } }); + addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + // When: Back reaches the AWS one-step form, still in update mode. + await user.click( + await screen.findByRole("button", { name: "Back to provider" }), + ); + await user.type( + await screen.findByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + const connect = screen.getByRole("button", { name: "Connect account" }); + await waitFor(() => expect(connect).toBeEnabled()); + await user.click(connect); + + // Then: an update never offers a scan. + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + expect(screen.queryByText("Launch scan")).not.toBeInTheDocument(); + }); + + it("returns to the one-step form when the launch step is stepped back from", async () => { // Given addProvider.mockResolvedValue({ data: { id: "provider-1" } }); addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); @@ -327,14 +422,12 @@ describe("provider wizard account creation", () => { const connect = screen.getByRole("button", { name: "Connect account" }); await waitFor(() => expect(connect).toBeEnabled()); await user.click(connect); - await screen.findByText("Connection test"); + await screen.findByText("Launch scan"); // When - await user.click( - screen.getByRole("button", { name: "Reset credentials" }), - ); + await user.click(screen.getByRole("button", { name: "Back to form" })); - // Then: AWS never had a separate credentials step, so it lands on its own form. + // Then: AWS has no separate credentials step, so it lands on its own form. expect( await screen.findByRole("textbox", { name: /Role ARN/ }), ).toBeVisible(); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index c86618fb1b..f5bf93f789 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -28,6 +28,7 @@ import type { ScanScheduleCapability } from "@/types/schedules"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { getCredentialsRetryStep, + getLaunchBackStep, getOrganizationsStepperOffset, getProviderWizardDocsDestination, getProviderWizardStepper, @@ -68,6 +69,7 @@ export function ProviderWizardModal({ handleClose, handleDialogOpenChange, handleTestSuccess, + isDirectCredentialsEntry, isOrgDirectEntry, isProviderFlow, mode, @@ -105,6 +107,7 @@ export function ProviderWizardModal({ mode, providerType: providerTypeHint, currentStep, + isDirectCredentialsEntry, }); return ( @@ -176,9 +179,10 @@ export function ProviderWizardModal({ endActiveTour(); }} onCredentialsSaved={() => { - // AWS stored its credentials in the connect step: skip ahead and - // end the tour like any other handoff to the user. - setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + // AWS stored its credentials and tested the connection in this + // step, so it takes the same exit the test step took: an update + // closes the wizard, an add moves on to the launch step. + handleTestSuccess(); endActiveTour(); }} onSelectOrganizations={openOrganizationsFlow} @@ -219,6 +223,7 @@ export function ProviderWizardModal({ getCredentialsRetryStep({ mode, providerType: providerTypeHint, + isDirectCredentialsEntry, }), ) } @@ -229,7 +234,14 @@ export function ProviderWizardModal({ {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} + onBack={() => + setCurrentStep( + getLaunchBackStep({ + providerType: providerTypeHint, + isDirectCredentialsEntry, + }), + ) + } onClose={handleClose} onFooterChange={setFooterConfig} capability={resolvedScanScheduleCapability} diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts index 5d19ae9d16..1664f2a4cb 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts @@ -9,6 +9,8 @@ import { import { type KnownProviderType, PROVIDER_TYPES } from "@/types/providers"; import { + getCredentialsRetryStep, + getLaunchBackStep, getOrganizationsStepperOffset, getProviderWizardDocsDestination, getProviderWizardModalTitle, @@ -34,41 +36,64 @@ describe("getProviderWizardStepper", () => { expect(stepper.stepOffset).toBe(0); }); - it("folds the credentials step into the first one when adding an AWS account", () => { + it("leaves only two rows when adding an AWS account", () => { const stepper = getProviderWizardStepper({ mode: PROVIDER_WIZARD_MODE.ADD, providerType: "aws", currentStep: PROVIDER_WIZARD_STEP.CONNECT, }); - expect(labels(stepper.steps)).toEqual([ - "Link a Provider", - "Validate Connection", - "Launch Scan", - ]); + expect(labels(stepper.steps)).toEqual(["Link a Provider", "Launch Scan"]); expect(stepper.stepOffset).toBe(0); }); - it("keeps the AWS stepper in sync once the wizard skips to the connection test", () => { - const stepper = getProviderWizardStepper({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - currentStep: PROVIDER_WIZARD_STEP.TEST, - }); - - // TEST is index 2 in the wizard but the second row of the AWS stepper. - expect(stepper.stepOffset).toBe(-1); - }); - - it("keeps the first AWS row active if the wizard ever lands on the credentials step", () => { + it("keeps the first AWS row active if the wizard ever lands on a folded step", () => { const stepper = getProviderWizardStepper({ mode: PROVIDER_WIZARD_MODE.ADD, providerType: "aws", currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS, }); - // CREDENTIALS has no row of its own for AWS: it folds into "Link a Provider". - expect(stepper.stepOffset).toBe(-1); + expect(stepper.stepOffset).toBe(-PROVIDER_WIZARD_STEP.CREDENTIALS); + }); + + it("puts the AWS launch step on the second row", () => { + const stepper = getProviderWizardStepper({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "aws", + currentStep: PROVIDER_WIZARD_STEP.LAUNCH, + }); + + // LAUNCH is index 3 in the wizard but the second row of the AWS stepper. + expect(stepper.stepOffset).toBe(-2); + }); + + it("keeps the generic rows when adding credentials to a registered AWS account", () => { + const stepper = getProviderWizardStepper({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "aws", + currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS, + isDirectCredentialsEntry: true, + }); + + expect(labels(stepper.steps)).toEqual([ + "Link a Provider", + "Authenticate Credentials", + "Validate Connection", + "Launch Scan", + ]); + expect(stepper.stepOffset).toBe(0); + }); + + it("keeps the generic rows for a provider that is not AWS", () => { + const stepper = getProviderWizardStepper({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "azure", + currentStep: PROVIDER_WIZARD_STEP.TEST, + }); + + expect(stepper.steps).toHaveLength(4); + expect(stepper.stepOffset).toBe(0); }); it("still shows the credentials step when updating AWS credentials", () => { @@ -87,6 +112,59 @@ describe("getProviderWizardStepper", () => { }); }); +describe("getCredentialsRetryStep", () => { + it("returns an AWS account being added to its one-step form", () => { + expect( + getCredentialsRetryStep({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "aws", + }), + ).toBe(PROVIDER_WIZARD_STEP.CONNECT); + }); + + it("returns to the credentials step when AWS credentials were added from the list", () => { + expect( + getCredentialsRetryStep({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "aws", + isDirectCredentialsEntry: true, + }), + ).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + }); + + it("returns every other provider to the credentials step", () => { + expect( + getCredentialsRetryStep({ + mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "azure", + }), + ).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + }); +}); + +describe("getLaunchBackStep", () => { + it("returns an AWS account to its one-step form", () => { + expect(getLaunchBackStep({ providerType: "aws" })).toBe( + PROVIDER_WIZARD_STEP.CONNECT, + ); + }); + + it("returns every other provider to the connection test", () => { + expect(getLaunchBackStep({ providerType: "azure" })).toBe( + PROVIDER_WIZARD_STEP.TEST, + ); + }); + + it("returns to the connection test when AWS credentials were added from the list", () => { + expect( + getLaunchBackStep({ + providerType: "aws", + isDirectCredentialsEntry: true, + }), + ).toBe(PROVIDER_WIZARD_STEP.TEST); + }); +}); + describe("getOrganizationsStepperOffset", () => { it("keeps step 1 active during organization details", () => { const offset = getOrganizationsStepperOffset( diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts index a320521c7e..1a3932ff47 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts @@ -22,10 +22,16 @@ const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice( PROVIDER_WIZARD_STEP.LAUNCH, ); +const AWS_CONNECT_STEPPER_ROW = 0; +const AWS_LAUNCH_STEPPER_ROW = 1; + interface ProviderWizardStepperInput { mode: ProviderWizardMode; providerType: ProviderType | null; currentStep: ProviderWizardStep; + // "Add credentials" on a registered account opens on CREDENTIALS and still walks + // the separate steps, so it keeps the generic rows. + isDirectCredentialsEntry?: boolean; } /** Rows for the provider-flow stepper plus the offset that maps `currentStep` onto them. */ @@ -33,14 +39,18 @@ export function getProviderWizardStepper({ mode, providerType, currentStep, + isDirectCredentialsEntry = false, }: ProviderWizardStepperInput) { if (mode === PROVIDER_WIZARD_MODE.UPDATE) { return { steps: UPDATE_MODE_WIZARD_STEPS, stepOffset: 0 }; } - if (providerType === "aws") { - // CONNECT stays on the first row; every later step shifts up one, so - // CREDENTIALS (no row of its own) folds into the first one too. - const stepOffset = currentStep === PROVIDER_WIZARD_STEP.CONNECT ? 0 : -1; + if (providerType === "aws" && !isDirectCredentialsEntry) { + // Only CONNECT and LAUNCH are reachable here; CREDENTIALS and TEST have no + // row of their own, so anything short of LAUNCH folds onto the first row. + const stepOffset = + currentStep === PROVIDER_WIZARD_STEP.LAUNCH + ? AWS_LAUNCH_STEPPER_ROW - PROVIDER_WIZARD_STEP.LAUNCH + : AWS_CONNECT_STEPPER_ROW - currentStep; return { steps: AWS_PROVIDER_WIZARD_STEPS, stepOffset }; } return { steps: PROVIDER_WIZARD_STEPS, stepOffset: 0 }; @@ -49,19 +59,41 @@ export function getProviderWizardStepper({ interface CredentialsRetryStepInput { mode: ProviderWizardMode; providerType: ProviderType | null; + isDirectCredentialsEntry?: boolean; } /** Where "Back" from the connection test lands: AWS re-enters its one-step form. */ export function getCredentialsRetryStep({ mode, providerType, + isDirectCredentialsEntry = false, }: CredentialsRetryStepInput): ProviderWizardStep { - if (mode === PROVIDER_WIZARD_MODE.ADD && providerType === "aws") { + if ( + mode === PROVIDER_WIZARD_MODE.ADD && + providerType === "aws" && + !isDirectCredentialsEntry + ) { return PROVIDER_WIZARD_STEP.CONNECT; } return PROVIDER_WIZARD_STEP.CREDENTIALS; } +interface LaunchBackStepInput { + providerType: ProviderType | null; + isDirectCredentialsEntry?: boolean; +} + +/** Where "Back" from the launch step lands: AWS returns to its one-step form. */ +export function getLaunchBackStep({ + providerType, + isDirectCredentialsEntry = false, +}: LaunchBackStepInput): ProviderWizardStep { + if (providerType === "aws" && !isDirectCredentialsEntry) { + return PROVIDER_WIZARD_STEP.CONNECT; + } + return PROVIDER_WIZARD_STEP.TEST; +} + export function getOrganizationsStepperOffset( currentStep: OrgWizardStep, setupPhase: OrgSetupPhase, diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx index 47475b471e..bf7a105f37 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,12 +12,21 @@ import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { AwsConnectStep } from "./aws-connect-step"; import type { AwsConnectUiState } from "./types"; -const { addProvider, addCredentialsProvider, openCloudUpgradeMock } = - vi.hoisted(() => ({ - addProvider: vi.fn(), - addCredentialsProvider: vi.fn(), - openCloudUpgradeMock: vi.fn(), - })); +const { + addProvider, + addCredentialsProvider, + updateProvider, + updateCredentialsProvider, + testProviderConnection, + openCloudUpgradeMock, +} = vi.hoisted(() => ({ + addProvider: vi.fn(), + addCredentialsProvider: vi.fn(), + updateProvider: vi.fn(), + updateCredentialsProvider: vi.fn(), + testProviderConnection: vi.fn(), + openCloudUpgradeMock: vi.fn(), +})); vi.mock("next-auth/react", () => ({ useSession: () => ({ @@ -28,7 +37,11 @@ vi.mock("next-auth/react", () => ({ vi.mock("@/actions/providers/providers", () => ({ addProvider, addCredentialsProvider, + updateProvider, + updateCredentialsProvider, })); +// The real module reaches next-auth through lib/helper -> auth.config. +vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection })); vi.mock("@/store", () => ({ useCloudUpgradeStore: ( selector: (state: { @@ -71,13 +84,18 @@ function Harness({ function renderStep() { const onConnected = vi.fn(); const onSelectOrganizations = vi.fn(); - render( + const { unmount } = render( , ); - return { onConnected, onSelectOrganizations, user: userEvent.setup() }; + return { + onConnected, + onSelectOrganizations, + unmount, + user: userEvent.setup(), + }; } const connectButton = () => @@ -97,6 +115,9 @@ describe("AwsConnectStep", () => { useProviderWizardStore.getState().reset(); addProvider.mockResolvedValue({ data: { id: "provider-1" } }); addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + updateProvider.mockResolvedValue({ data: { id: "provider-1" } }); + updateCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + testProviderConnection.mockResolvedValue({ connected: true, error: null }); }); afterEach(() => { @@ -293,6 +314,230 @@ describe("AwsConnectStep", () => { }); }); + describe("when the connection is tested", () => { + beforeEach(() => { + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + }); + + const submitRole = async () => { + const step = renderStep(); + await step.user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + await waitFor(() => expect(connectButton()).toBeEnabled()); + await step.user.click(connectButton()); + return step; + }; + + const submitKeys = async () => { + const step = renderStep(); + await step.user.click( + screen.getByRole("radio", { name: /Static access keys/ }), + ); + await step.user.type( + screen.getByRole("textbox", { name: /Account ID/ }), + "210987654321", + ); + await step.user.type( + screen.getByPlaceholderText("Enter the AWS Access Key ID"), + "AKIAEXAMPLE", + ); + await step.user.type( + screen.getByPlaceholderText("Enter the AWS Secret Access Key"), + "secret-value", + ); + await waitFor(() => expect(connectButton()).toBeEnabled()); + await step.user.click(connectButton()); + return step; + }; + + it("reports the test in progress and blocks the action while it runs", async () => { + // Given: a test that has not answered yet. + let settle!: (result: { + connected: boolean; + error: string | null; + }) => void; + testProviderConnection.mockImplementation( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + + // When + const { onConnected } = await submitRole(); + + // Then + expect(await screen.findByRole("status")).toHaveTextContent( + /testing the connection/i, + ); + expect( + screen.getByRole("button", { name: "Testing connection..." }), + ).toBeDisabled(); + expect(onConnected).not.toHaveBeenCalled(); + + // When / Then + await act(async () => settle({ connected: true, error: null })); + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + }); + + it("ignores a result that lands after the step was closed", async () => { + // Given: the wizard is closed (or switched to organizations) mid-test. + let settle!: (result: { + connected: boolean; + error: string | null; + }) => void; + testProviderConnection.mockImplementation( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + const { onConnected, unmount } = await submitRole(); + await screen.findByRole("status"); + + // When + unmount(); + await act(async () => settle({ connected: true, error: null })); + + // Then: a reset wizard must not be pushed to the launch step. + expect(onConnected).not.toHaveBeenCalled(); + }); + + it("tests the account that was connected with static keys too", async () => { + // When + const { onConnected } = await submitKeys(); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect(testProviderConnection).toHaveBeenCalledWith("provider-1"); + }); + + it("stays on the keys form when the connection is refused", async () => { + // Given + testProviderConnection.mockResolvedValue({ + connected: false, + error: "The access keys were rejected.", + }); + + // When + const { onConnected } = await submitKeys(); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent( + "The access keys were rejected.", + ); + expect(onConnected).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: /Account ID/ })).toBeVisible(); + }); + + it("tests the registered account before leaving the step", async () => { + // When + const { onConnected } = await submitRole(); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect(testProviderConnection).toHaveBeenCalledWith("provider-1"); + }); + + it("stays on the form and offers a retry when the connection is refused", async () => { + // Given + testProviderConnection.mockResolvedValue({ + connected: false, + error: "The role could not be assumed.", + }); + + // When + const { onConnected } = await submitRole(); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent( + "The role could not be assumed.", + ); + expect(onConnected).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Retry connection" }), + ).toBeEnabled(); + }); + + // The helper always supplies a reason today; this guards the alert against a + // future contract that does not. + it("falls back to a generic reason when the API gives none", async () => { + // Given + testProviderConnection.mockResolvedValue({ + connected: false, + error: null, + }); + + // When + await submitRole(); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent( + /could not connect/i, + ); + }); + + it("recovers when the connection test itself fails", async () => { + // Given: task polling rejects on a 5xx instead of reporting a failure. + testProviderConnection.mockRejectedValue(new Error("Server error (500)")); + + // When + const { onConnected } = await submitRole(); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent( + /account is saved/i, + ); + expect(onConnected).not.toHaveBeenCalled(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Retry connection" }), + ).toBeEnabled(), + ); + }); + + it("drops the failure as soon as the form is edited again", async () => { + // Given + testProviderConnection.mockResolvedValue({ + connected: false, + error: "The role could not be assumed.", + }); + const { user } = await submitRole(); + await screen.findByRole("alert"); + + // When + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + "-extra", + ); + + // Then + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + }); + + it("moves on once a retry connects", async () => { + // Given + testProviderConnection + .mockResolvedValueOnce({ connected: false, error: "Denied." }) + .mockResolvedValueOnce({ connected: true, error: null }); + const { onConnected, user } = await submitRole(); + + // When + await user.click( + await screen.findByRole("button", { name: "Retry connection" }), + ); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + // The account is registered once; the retry only rewrites its secret. + expect(addProvider).toHaveBeenCalledOnce(); + }); + }); + describe("when the step is left and reopened within the same wizard", () => { beforeEach(() => { vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx index 1b0d1ed47b..32f8727f99 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx @@ -1,9 +1,15 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronDownIcon, KeyRound, ShieldCheck } from "lucide-react"; +import { + ChevronDownIcon, + CircleAlert, + KeyRound, + Loader2, + ShieldCheck, +} from "lucide-react"; import { useSession } from "next-auth/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Control, FieldValues, @@ -29,6 +35,7 @@ import { } from "@/components/shadcn/collapsible"; import { Form } from "@/components/shadcn/form"; import { useFormServerErrors } from "@/hooks/use-form-server-errors"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings"; import { getAWSCredentialsTemplateLinks } from "@/lib/external-urls"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; @@ -37,6 +44,7 @@ import { dispatchProviderFunnel, PROVIDER_FUNNEL_STEP, } from "@/lib/provider-funnel/provider-funnel-events"; +import { testProviderConnection } from "@/lib/provider-helpers"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import type { AWSCredentials, AWSCredentialsRole } from "@/types"; import type { AwsConnectDraft } from "@/types/provider-wizard"; @@ -66,7 +74,7 @@ const ALIAS_ERROR_POINTER = "/data/attributes/alias"; const UNIQUE_TOGETHER_ERROR_POINTER = "/data/attributes/__all__"; // What the user typed survives the step unmounting (organizations tab, a step -// back from the connection test) until the wizard closes. +// back from the launch step) until the wizard closes. const readDraft = () => useProviderWizardStore.getState().awsConnectDraft; const initialMethod = (): AwsAccessMethod => @@ -93,7 +101,7 @@ interface AwsConnectStepProps { onUiStateChange: (state: AwsConnectUiState) => void; } -/** One form to register an AWS account and store its credentials. */ +/** One form to register an AWS account, store its credentials and test the connection. */ export function AwsConnectStep({ formId, onConnected, @@ -189,6 +197,28 @@ interface UseAwsConnectSubmitOptions { onUiStateChange: (state: AwsConnectUiState) => void; } +const CONNECTION_FAILED_MESSAGE = + "Prowler could not connect with these credentials. Review them and try again."; + +const CONNECTION_UNREACHABLE_MESSAGE = + "The connection test could not be completed. The account is saved, so you can try again."; + +/** Footer label for the one-step form: the test and the retry share the submit. */ +const resolveActionLabel = ({ + isTesting, + isSubmitting, + hasFailed, +}: { + isTesting: boolean; + isSubmitting: boolean; + hasFailed: boolean; +}) => { + if (isTesting) return "Testing connection..."; + if (isSubmitting) return "Connecting account..."; + return hasFailed ? "Retry connection" : "Connect account"; +}; + +/** Registers the account, stores its credentials and tests the connection in one submit. */ function useAwsConnectSubmit({ form, method, @@ -205,25 +235,59 @@ function useAwsConnectSubmit({ [UNIQUE_TOGETHER_ERROR_POINTER]: accountField, [ALIAS_ERROR_POINTER]: ProviderCredentialFields.PROVIDER_ALIAS, }); + // Local state needed: the connection test runs inside the submit, and its + // outcome belongs to this step rather than to any form field. + const [isTesting, setIsTesting] = useState(false); + const [connectionError, setConnectionError] = useState(null); // A hook, not `form.formState.isValid` read inline: the React Compiler keys // its memo on the stable `form` object and would freeze a proxy read at false. const { isSubmitting, isValid } = useFormState({ control: form.control }); const canSubmit = isValid && accountResolved; + const isBusy = isSubmitting || isTesting; + // Closing the wizard (or switching to organizations) unmounts the step while a + // test may still be running; its result must not advance a wizard already reset. + const isActiveRef = useRef(true); + useMountEffect(() => { + isActiveRef.current = true; + return () => { + isActiveRef.current = false; + }; + }); // Same contract ConnectAccountForm uses: the wizard footer lives outside the step. // Both callbacks must be stable setters, or this effect would loop. useEffect(() => { - onBusyChange(isSubmitting); + onBusyChange(isBusy); onUiStateChange({ showBack: true, showAction: true, - actionLabel: isSubmitting ? "Connecting account..." : "Connect account", - actionDisabled: !canSubmit || isSubmitting, - isLoading: isSubmitting, + actionLabel: resolveActionLabel({ + isTesting, + isSubmitting, + hasFailed: connectionError !== null, + }), + actionDisabled: !canSubmit || isBusy, + isLoading: isBusy, }); - }, [canSubmit, isSubmitting, onBusyChange, onUiStateChange]); + }, [ + canSubmit, + connectionError, + isBusy, + isSubmitting, + isTesting, + onBusyChange, + onUiStateChange, + ]); - return form.handleSubmit(async (values) => { + // A past failure must not sit above the field the user is already correcting. + useEffect(() => { + if (connectionError === null) return; + const subscription = form.watch(() => setConnectionError(null)); + return () => subscription.unsubscribe(); + }, [connectionError, form]); + + const onSubmit = form.handleSubmit(async (values) => { + setConnectionError(null); const result = await connectAwsAccount({ method, values: { ...values, ...extraValues }, @@ -241,8 +305,77 @@ function useAwsConnectSubmit({ handleServerResponse({ errors: result.errors }); return; } - onConnected(); + + // The account stays registered whatever the test says; resubmitting edits it + // in place. Task polling rejects on a 5xx, so the flag has to be cleared in a + // finally or the step would stay stuck on "Testing connection...". + let connected = false; + setIsTesting(true); + try { + const connection = await testProviderConnection(result.providerId); + connected = connection.connected; + if (!connected) { + setConnectionError(connection.error || CONNECTION_FAILED_MESSAGE); + } + } catch { + setConnectionError(CONNECTION_UNREACHABLE_MESSAGE); + } finally { + setIsTesting(false); + } + + if (connected && isActiveRef.current) onConnected(); }); + + return { onSubmit, isTesting, connectionError }; +} + +/** Progress line while the test runs, or the API's reason once it is refused. */ +function ConnectionFeedback({ + isTesting, + error, +}: { + isTesting: boolean; + error: string | null; +}) { + const alertRef = useRef(null); + + // The form scrolls inside the modal and the action button sits outside it, so + // an error raised from the footer can land above the fold. + useEffect(() => { + if (!error) return; + // Guarded: jsdom has no scrollIntoView, and a throw here would unmount the step. + alertRef.current?.scrollIntoView?.({ block: "start", behavior: "smooth" }); + }, [error]); + + if (isTesting) { + return ( +

+ + Testing the connection. This usually takes a few seconds. +

+ ); + } + + if (!error) return null; + + return ( +
+ +

+ {error} +

+
+ ); } function AwsRoleConnectForm({ @@ -284,7 +417,7 @@ function AwsRoleConnectForm({ }); const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? ""); - const onSubmit = useAwsConnectSubmit({ + const { onSubmit, isTesting, connectionError } = useAwsConnectSubmit({ form, method: AWS_ACCESS_METHOD.ROLE, accountField: ProviderCredentialFields.ROLE_ARN, @@ -304,6 +437,8 @@ function AwsRoleConnectForm({ return (
+ +

1. Create the IAM role

+ + { }); // Then - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, providerId: "provider-1" }); expect(formEntries(0, addProvider)).toEqual({ providerType: "aws", providerUid: "123456789012", @@ -138,7 +138,7 @@ describe("connectAwsAccount", () => { }); // Then - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, providerId: "provider-1" }); expect(formEntries(0, addProvider)).toEqual({ providerType: "aws", providerUid: "210987654321", @@ -262,7 +262,7 @@ describe("connectAwsAccount", () => { // Then expect(first).toEqual({ ok: false, errors }); - expect(second).toEqual({ ok: true }); + expect(second).toEqual({ ok: true, providerId: "provider-1" }); expect(addProvider).toHaveBeenCalledOnce(); expect(addCredentialsProvider).toHaveBeenCalledTimes(2); expect(updateProvider).not.toHaveBeenCalled(); @@ -285,7 +285,7 @@ describe("connectAwsAccount", () => { }); // Then - expect(second).toEqual({ ok: true }); + expect(second).toEqual({ ok: true, providerId: "provider-1" }); expect(addProvider).toHaveBeenCalledOnce(); expect(formEntries(0, updateProvider)).toEqual({ providerId: "provider-1", @@ -318,7 +318,7 @@ describe("connectAwsAccount", () => { }); // Then - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, providerId: "provider-1" }); expect(addProvider).toHaveBeenCalledOnce(); expect(addCredentialsProvider).toHaveBeenCalledOnce(); expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith( diff --git a/ui/components/providers/wizard/steps/aws/connect-aws-account.ts b/ui/components/providers/wizard/steps/aws/connect-aws-account.ts index cf1115f4c5..009a28e117 100644 --- a/ui/components/providers/wizard/steps/aws/connect-aws-account.ts +++ b/ui/components/providers/wizard/steps/aws/connect-aws-account.ts @@ -31,6 +31,7 @@ export interface AwsConnectInput { interface AwsConnectSuccess { ok: true; + providerId: string; } interface AwsConnectFailure { @@ -187,5 +188,5 @@ export async function connectAwsAccount( const store = useProviderWizardStore.getState(); store.setSecretId(secret.id); store.setVia(input.method); - return { ok: true }; + return { ok: true, providerId: provider.providerId }; } diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index f6e004511b..23b45b46c0 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -21,7 +21,7 @@ import { interface ConnectStepProps { onNext: () => void; - /** AWS registers the account and its credentials in this step, so it skips ahead. */ + /** AWS registers, stores and tests the account in this step, so it skips ahead. */ onCredentialsSaved: () => void; onSelectOrganizations: (orgType: OrgFlowType) => void; onFooterChange: (config: WizardFooterConfig) => void; diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx index 6c00e5ca4b..5ef115eff5 100644 --- a/ui/components/providers/wizard/wizard-stepper.tsx +++ b/ui/components/providers/wizard/wizard-stepper.tsx @@ -46,16 +46,15 @@ const STEPS: StepConfig[] = [ export const PROVIDER_WIZARD_STEPS = STEPS; -// AWS registers the account and its credentials in one step, so the wizard -// skips straight from CONNECT to TEST; the stepper mirrors that. +// AWS registers the account, stores its credentials and tests the connection in +// one step, so the wizard goes straight from CONNECT to LAUNCH. export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [ { label: "Link a Provider", description: - "Enter the account details and the credentials Prowler will use.", + "Enter the account details and the credentials Prowler will use, then test the connection.", icon: FolderGit2, }, - STEPS[2], STEPS[3], ]; diff --git a/ui/lib/tours/add-provider.tour.ts b/ui/lib/tours/add-provider.tour.ts index ba0c5e22be..6c4831babd 100644 --- a/ui/lib/tours/add-provider.tour.ts +++ b/ui/lib/tours/add-provider.tour.ts @@ -63,7 +63,7 @@ export const addProviderTour = defineTour({ autoAdvance: true, title: "Add your account details", description: - "Fill in the connection details for this provider, then continue. From here you'll test the connection and launch your first scan — at your own pace.", + "Fill in the connection details for this provider, then continue. Prowler checks the connection, then you launch your first scan — at your own pace.", }, ], });