mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): connect and test AWS accounts in one step (#12876)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
2c233c2f6c
commit
dc67fe4f37
@@ -89,6 +89,10 @@ After adding your cloud account credentials, click the `Check connection` button
|
|||||||
|
|
||||||
<img src="/images/test-connection-button.png" alt="Test Connection" width="700" />
|
<img src="/images/test-connection-button.png" alt="Test Connection" width="700" />
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
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.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Step 6: Scan Started
|
## 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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
|||||||
return this.countRequests("POST", "/apply");
|
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 {
|
get providerCreateCallCount(): number {
|
||||||
return (
|
return this.requestLog.filter(
|
||||||
this.countRequests("POST", "/providers") - this.secretCreateCallCount
|
(request) =>
|
||||||
);
|
request.method === "POST" &&
|
||||||
|
new URL(request.url).pathname.replace(/\/$/, "").endsWith("/providers"),
|
||||||
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get secretCreateCallCount(): number {
|
get secretCreateCallCount(): number {
|
||||||
@@ -94,7 +96,7 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get connectionCallCount(): number {
|
get connectionCallCount(): number {
|
||||||
return this.countRequests("POST", "/connection");
|
return this.countRequests("POST", "/connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +265,9 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
|||||||
await this.clickPrimary(/Connect account/);
|
await this.clickPrimary(/Connect account/);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wait until the connection test step is showing with its action ready. */
|
/** Wait until the provider wizard reached its launch step. */
|
||||||
async waitForConnectionTestStep(): Promise<void> {
|
async waitForProviderLaunchStep(timeoutMs = 20000): Promise<void> {
|
||||||
await this.waitForButton(/Check connection/, 10000);
|
await this.waitForText(/Scan Schedule/, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch back to a single account from the organization flow's tabs. */
|
/** Switch back to a single account from the organization flow's tabs. */
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ describe("Organization onboarding wizard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Wizard progress", () => {
|
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());
|
const harness = new ProvidersPageHarness(awsOnboardingFixture());
|
||||||
await harness.mount();
|
await harness.mount();
|
||||||
expect(harness.stepperLabels()).toEqual([
|
expect(harness.stepperLabels()).toEqual([
|
||||||
@@ -228,7 +228,6 @@ describe("Organization onboarding wizard", () => {
|
|||||||
|
|
||||||
expect(harness.stepperLabels()).toEqual([
|
expect(harness.stepperLabels()).toEqual([
|
||||||
"Link a Provider",
|
"Link a Provider",
|
||||||
"Validate Connection",
|
|
||||||
"Launch Scan",
|
"Launch Scan",
|
||||||
]);
|
]);
|
||||||
}, 40000);
|
}, 40000);
|
||||||
@@ -237,7 +236,7 @@ describe("Organization onboarding wizard", () => {
|
|||||||
describe("Single account with access keys", () => {
|
describe("Single account with access keys", () => {
|
||||||
// Runs compiled by the React Compiler, unlike the unit suite: it guards the
|
// 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.
|
// 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());
|
const harness = new ProvidersPageHarness(awsOnboardingFixture());
|
||||||
await harness.mount();
|
await harness.mount();
|
||||||
await harness.selectProviderType(/Amazon Web Services/);
|
await harness.selectProviderType(/Amazon Web Services/);
|
||||||
@@ -251,9 +250,12 @@ describe("Organization onboarding wizard", () => {
|
|||||||
|
|
||||||
await harness.connectAccount();
|
await harness.connectAccount();
|
||||||
|
|
||||||
await harness.waitForConnectionTestStep();
|
await harness.waitForProviderLaunchStep();
|
||||||
expect(harness.providerCreateCallCount).toBe(1);
|
expect(harness.providerCreateCallCount).toBe(1);
|
||||||
expect(harness.secretCreateCallCount).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<{
|
const secret = await harness.lastRequestBody<{
|
||||||
data: { relationships: { provider: { data: { id: string } } } };
|
data: { relationships: { provider: { data: { id: string } } } };
|
||||||
}>("POST", "/providers/secrets");
|
}>("POST", "/providers/secrets");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ export function useProviderWizardController({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const isOrgDirectEntry = Boolean(orgInitialData);
|
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 = () => {
|
const handleClose = () => {
|
||||||
// Closing the wizard at any point ends the add-provider tour; the checkpoint
|
// Closing the wizard at any point ends the add-provider tour; the checkpoint
|
||||||
@@ -323,6 +328,7 @@ export function useProviderWizardController({
|
|||||||
handleClose,
|
handleClose,
|
||||||
handleDialogOpenChange,
|
handleDialogOpenChange,
|
||||||
handleTestSuccess,
|
handleTestSuccess,
|
||||||
|
isDirectCredentialsEntry,
|
||||||
isOrgDirectEntry,
|
isOrgDirectEntry,
|
||||||
isProviderFlow,
|
isProviderFlow,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ const {
|
|||||||
addProvider,
|
addProvider,
|
||||||
addRegistryProvider,
|
addRegistryProvider,
|
||||||
getInstalledRegistryProviderOptions,
|
getInstalledRegistryProviderOptions,
|
||||||
|
testProviderConnection,
|
||||||
|
updateCredentialsProvider,
|
||||||
|
updateProvider,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
addCredentialsProvider: vi.fn(),
|
addCredentialsProvider: vi.fn(),
|
||||||
addProvider: vi.fn(),
|
addProvider: vi.fn(),
|
||||||
addRegistryProvider: vi.fn(),
|
addRegistryProvider: vi.fn(),
|
||||||
getInstalledRegistryProviderOptions: vi.fn(),
|
getInstalledRegistryProviderOptions: vi.fn(),
|
||||||
|
testProviderConnection: vi.fn(),
|
||||||
|
updateCredentialsProvider: vi.fn(),
|
||||||
|
updateProvider: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
@@ -38,7 +44,11 @@ vi.mock("next-auth/react", () => ({
|
|||||||
vi.mock("@/actions/providers/providers", () => ({
|
vi.mock("@/actions/providers/providers", () => ({
|
||||||
addCredentialsProvider,
|
addCredentialsProvider,
|
||||||
addProvider,
|
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", () => ({
|
vi.mock("@/actions/providers/registry-provider", () => ({
|
||||||
addRegistryProvider,
|
addRegistryProvider,
|
||||||
}));
|
}));
|
||||||
@@ -57,7 +67,14 @@ vi.mock("@/lib/tours/use-driver-tour", () => ({
|
|||||||
endActiveTour: vi.fn(),
|
endActiveTour: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("./steps/credentials-step", () => ({
|
vi.mock("./steps/credentials-step", () => ({
|
||||||
CredentialsStep: () => <p>Credential details</p>,
|
CredentialsStep: ({ onBack }: { onBack: () => void }) => (
|
||||||
|
<>
|
||||||
|
<p>Credential details</p>
|
||||||
|
<button type="button" onClick={onBack}>
|
||||||
|
Back to provider
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("./steps/test-connection-step", () => ({
|
vi.mock("./steps/test-connection-step", () => ({
|
||||||
TestConnectionStep: ({
|
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 }) => (
|
||||||
|
<>
|
||||||
|
<p>Launch scan</p>
|
||||||
|
<button type="button" onClick={onBack}>
|
||||||
|
Back to form
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock("../organizations/azure-org-setup-form", () => ({
|
vi.mock("../organizations/azure-org-setup-form", () => ({
|
||||||
AzureOrgSetupForm: () => null,
|
AzureOrgSetupForm: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -128,6 +154,9 @@ describe("provider wizard account creation", () => {
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
options: [{ type: "acme", label: "Acme Cloud" }],
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -305,9 +334,11 @@ describe("provider wizard account creation", () => {
|
|||||||
await waitFor(() => expect(connect).toBeEnabled());
|
await waitFor(() => expect(connect).toBeEnabled());
|
||||||
await user.click(connect);
|
await user.click(connect);
|
||||||
|
|
||||||
// Then: the separate credentials step never shows up.
|
// Then: neither the credentials step nor the connection test shows up.
|
||||||
expect(await screen.findByText("Connection test")).toBeVisible();
|
expect(await screen.findByText("Launch scan")).toBeVisible();
|
||||||
expect(screen.queryByText("Credential details")).not.toBeInTheDocument();
|
expect(screen.queryByText("Credential details")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Connection test")).not.toBeInTheDocument();
|
||||||
|
expect(testProviderConnection).toHaveBeenCalledWith("provider-1");
|
||||||
expect(useProviderWizardStore.getState()).toMatchObject({
|
expect(useProviderWizardStore.getState()).toMatchObject({
|
||||||
providerId: "provider-1",
|
providerId: "provider-1",
|
||||||
secretId: "secret-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(
|
||||||
|
<ProviderWizardModal
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
initialData={{
|
||||||
|
providerId: "provider-1",
|
||||||
|
providerType: "aws",
|
||||||
|
providerUid: "123456789012",
|
||||||
|
providerAlias: null,
|
||||||
|
secretId: "secret-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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
|
// Given
|
||||||
addProvider.mockResolvedValue({ data: { id: "provider-1" } });
|
addProvider.mockResolvedValue({ data: { id: "provider-1" } });
|
||||||
addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } });
|
addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } });
|
||||||
@@ -327,14 +422,12 @@ describe("provider wizard account creation", () => {
|
|||||||
const connect = screen.getByRole("button", { name: "Connect account" });
|
const connect = screen.getByRole("button", { name: "Connect account" });
|
||||||
await waitFor(() => expect(connect).toBeEnabled());
|
await waitFor(() => expect(connect).toBeEnabled());
|
||||||
await user.click(connect);
|
await user.click(connect);
|
||||||
await screen.findByText("Connection test");
|
await screen.findByText("Launch scan");
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "Back to form" }));
|
||||||
screen.getByRole("button", { name: "Reset credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
expect(
|
||||||
await screen.findByRole("textbox", { name: /Role ARN/ }),
|
await screen.findByRole("textbox", { name: /Role ARN/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { ScanScheduleCapability } from "@/types/schedules";
|
|||||||
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
|
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
|
||||||
import {
|
import {
|
||||||
getCredentialsRetryStep,
|
getCredentialsRetryStep,
|
||||||
|
getLaunchBackStep,
|
||||||
getOrganizationsStepperOffset,
|
getOrganizationsStepperOffset,
|
||||||
getProviderWizardDocsDestination,
|
getProviderWizardDocsDestination,
|
||||||
getProviderWizardStepper,
|
getProviderWizardStepper,
|
||||||
@@ -68,6 +69,7 @@ export function ProviderWizardModal({
|
|||||||
handleClose,
|
handleClose,
|
||||||
handleDialogOpenChange,
|
handleDialogOpenChange,
|
||||||
handleTestSuccess,
|
handleTestSuccess,
|
||||||
|
isDirectCredentialsEntry,
|
||||||
isOrgDirectEntry,
|
isOrgDirectEntry,
|
||||||
isProviderFlow,
|
isProviderFlow,
|
||||||
mode,
|
mode,
|
||||||
@@ -105,6 +107,7 @@ export function ProviderWizardModal({
|
|||||||
mode,
|
mode,
|
||||||
providerType: providerTypeHint,
|
providerType: providerTypeHint,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
isDirectCredentialsEntry,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,9 +179,10 @@ export function ProviderWizardModal({
|
|||||||
endActiveTour();
|
endActiveTour();
|
||||||
}}
|
}}
|
||||||
onCredentialsSaved={() => {
|
onCredentialsSaved={() => {
|
||||||
// AWS stored its credentials in the connect step: skip ahead and
|
// AWS stored its credentials and tested the connection in this
|
||||||
// end the tour like any other handoff to the user.
|
// step, so it takes the same exit the test step took: an update
|
||||||
setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
|
// closes the wizard, an add moves on to the launch step.
|
||||||
|
handleTestSuccess();
|
||||||
endActiveTour();
|
endActiveTour();
|
||||||
}}
|
}}
|
||||||
onSelectOrganizations={openOrganizationsFlow}
|
onSelectOrganizations={openOrganizationsFlow}
|
||||||
@@ -219,6 +223,7 @@ export function ProviderWizardModal({
|
|||||||
getCredentialsRetryStep({
|
getCredentialsRetryStep({
|
||||||
mode,
|
mode,
|
||||||
providerType: providerTypeHint,
|
providerType: providerTypeHint,
|
||||||
|
isDirectCredentialsEntry,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -229,7 +234,14 @@ export function ProviderWizardModal({
|
|||||||
{isProviderFlow &&
|
{isProviderFlow &&
|
||||||
currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
|
currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
|
||||||
<LaunchStep
|
<LaunchStep
|
||||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
onBack={() =>
|
||||||
|
setCurrentStep(
|
||||||
|
getLaunchBackStep({
|
||||||
|
providerType: providerTypeHint,
|
||||||
|
isDirectCredentialsEntry,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onFooterChange={setFooterConfig}
|
onFooterChange={setFooterConfig}
|
||||||
capability={resolvedScanScheduleCapability}
|
capability={resolvedScanScheduleCapability}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { type KnownProviderType, PROVIDER_TYPES } from "@/types/providers";
|
import { type KnownProviderType, PROVIDER_TYPES } from "@/types/providers";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getCredentialsRetryStep,
|
||||||
|
getLaunchBackStep,
|
||||||
getOrganizationsStepperOffset,
|
getOrganizationsStepperOffset,
|
||||||
getProviderWizardDocsDestination,
|
getProviderWizardDocsDestination,
|
||||||
getProviderWizardModalTitle,
|
getProviderWizardModalTitle,
|
||||||
@@ -34,41 +36,64 @@ describe("getProviderWizardStepper", () => {
|
|||||||
expect(stepper.stepOffset).toBe(0);
|
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({
|
const stepper = getProviderWizardStepper({
|
||||||
mode: PROVIDER_WIZARD_MODE.ADD,
|
mode: PROVIDER_WIZARD_MODE.ADD,
|
||||||
providerType: "aws",
|
providerType: "aws",
|
||||||
currentStep: PROVIDER_WIZARD_STEP.CONNECT,
|
currentStep: PROVIDER_WIZARD_STEP.CONNECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(labels(stepper.steps)).toEqual([
|
expect(labels(stepper.steps)).toEqual(["Link a Provider", "Launch Scan"]);
|
||||||
"Link a Provider",
|
|
||||||
"Validate Connection",
|
|
||||||
"Launch Scan",
|
|
||||||
]);
|
|
||||||
expect(stepper.stepOffset).toBe(0);
|
expect(stepper.stepOffset).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the AWS stepper in sync once the wizard skips to the connection test", () => {
|
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.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", () => {
|
|
||||||
const stepper = getProviderWizardStepper({
|
const stepper = getProviderWizardStepper({
|
||||||
mode: PROVIDER_WIZARD_MODE.ADD,
|
mode: PROVIDER_WIZARD_MODE.ADD,
|
||||||
providerType: "aws",
|
providerType: "aws",
|
||||||
currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS,
|
currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS,
|
||||||
});
|
});
|
||||||
|
|
||||||
// CREDENTIALS has no row of its own for AWS: it folds into "Link a Provider".
|
expect(stepper.stepOffset).toBe(-PROVIDER_WIZARD_STEP.CREDENTIALS);
|
||||||
expect(stepper.stepOffset).toBe(-1);
|
});
|
||||||
|
|
||||||
|
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", () => {
|
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", () => {
|
describe("getOrganizationsStepperOffset", () => {
|
||||||
it("keeps step 1 active during organization details", () => {
|
it("keeps step 1 active during organization details", () => {
|
||||||
const offset = getOrganizationsStepperOffset(
|
const offset = getOrganizationsStepperOffset(
|
||||||
|
|||||||
@@ -22,10 +22,16 @@ const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
|
|||||||
PROVIDER_WIZARD_STEP.LAUNCH,
|
PROVIDER_WIZARD_STEP.LAUNCH,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AWS_CONNECT_STEPPER_ROW = 0;
|
||||||
|
const AWS_LAUNCH_STEPPER_ROW = 1;
|
||||||
|
|
||||||
interface ProviderWizardStepperInput {
|
interface ProviderWizardStepperInput {
|
||||||
mode: ProviderWizardMode;
|
mode: ProviderWizardMode;
|
||||||
providerType: ProviderType | null;
|
providerType: ProviderType | null;
|
||||||
currentStep: ProviderWizardStep;
|
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. */
|
/** Rows for the provider-flow stepper plus the offset that maps `currentStep` onto them. */
|
||||||
@@ -33,14 +39,18 @@ export function getProviderWizardStepper({
|
|||||||
mode,
|
mode,
|
||||||
providerType,
|
providerType,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
isDirectCredentialsEntry = false,
|
||||||
}: ProviderWizardStepperInput) {
|
}: ProviderWizardStepperInput) {
|
||||||
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
|
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
|
||||||
return { steps: UPDATE_MODE_WIZARD_STEPS, stepOffset: 0 };
|
return { steps: UPDATE_MODE_WIZARD_STEPS, stepOffset: 0 };
|
||||||
}
|
}
|
||||||
if (providerType === "aws") {
|
if (providerType === "aws" && !isDirectCredentialsEntry) {
|
||||||
// CONNECT stays on the first row; every later step shifts up one, so
|
// Only CONNECT and LAUNCH are reachable here; CREDENTIALS and TEST have no
|
||||||
// CREDENTIALS (no row of its own) folds into the first one too.
|
// row of their own, so anything short of LAUNCH folds onto the first row.
|
||||||
const stepOffset = currentStep === PROVIDER_WIZARD_STEP.CONNECT ? 0 : -1;
|
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: AWS_PROVIDER_WIZARD_STEPS, stepOffset };
|
||||||
}
|
}
|
||||||
return { steps: PROVIDER_WIZARD_STEPS, stepOffset: 0 };
|
return { steps: PROVIDER_WIZARD_STEPS, stepOffset: 0 };
|
||||||
@@ -49,19 +59,41 @@ export function getProviderWizardStepper({
|
|||||||
interface CredentialsRetryStepInput {
|
interface CredentialsRetryStepInput {
|
||||||
mode: ProviderWizardMode;
|
mode: ProviderWizardMode;
|
||||||
providerType: ProviderType | null;
|
providerType: ProviderType | null;
|
||||||
|
isDirectCredentialsEntry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where "Back" from the connection test lands: AWS re-enters its one-step form. */
|
/** Where "Back" from the connection test lands: AWS re-enters its one-step form. */
|
||||||
export function getCredentialsRetryStep({
|
export function getCredentialsRetryStep({
|
||||||
mode,
|
mode,
|
||||||
providerType,
|
providerType,
|
||||||
|
isDirectCredentialsEntry = false,
|
||||||
}: CredentialsRetryStepInput): ProviderWizardStep {
|
}: 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.CONNECT;
|
||||||
}
|
}
|
||||||
return PROVIDER_WIZARD_STEP.CREDENTIALS;
|
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(
|
export function getOrganizationsStepperOffset(
|
||||||
currentStep: OrgWizardStep,
|
currentStep: OrgWizardStep,
|
||||||
setupPhase: OrgSetupPhase,
|
setupPhase: OrgSetupPhase,
|
||||||
|
|||||||
@@ -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 userEvent from "@testing-library/user-event";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { AwsConnectStep } from "./aws-connect-step";
|
||||||
import type { AwsConnectUiState } from "./types";
|
import type { AwsConnectUiState } from "./types";
|
||||||
|
|
||||||
const { addProvider, addCredentialsProvider, openCloudUpgradeMock } =
|
const {
|
||||||
vi.hoisted(() => ({
|
addProvider,
|
||||||
addProvider: vi.fn(),
|
addCredentialsProvider,
|
||||||
addCredentialsProvider: vi.fn(),
|
updateProvider,
|
||||||
openCloudUpgradeMock: vi.fn(),
|
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", () => ({
|
vi.mock("next-auth/react", () => ({
|
||||||
useSession: () => ({
|
useSession: () => ({
|
||||||
@@ -28,7 +37,11 @@ vi.mock("next-auth/react", () => ({
|
|||||||
vi.mock("@/actions/providers/providers", () => ({
|
vi.mock("@/actions/providers/providers", () => ({
|
||||||
addProvider,
|
addProvider,
|
||||||
addCredentialsProvider,
|
addCredentialsProvider,
|
||||||
|
updateProvider,
|
||||||
|
updateCredentialsProvider,
|
||||||
}));
|
}));
|
||||||
|
// The real module reaches next-auth through lib/helper -> auth.config.
|
||||||
|
vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection }));
|
||||||
vi.mock("@/store", () => ({
|
vi.mock("@/store", () => ({
|
||||||
useCloudUpgradeStore: (
|
useCloudUpgradeStore: (
|
||||||
selector: (state: {
|
selector: (state: {
|
||||||
@@ -71,13 +84,18 @@ function Harness({
|
|||||||
function renderStep() {
|
function renderStep() {
|
||||||
const onConnected = vi.fn();
|
const onConnected = vi.fn();
|
||||||
const onSelectOrganizations = vi.fn();
|
const onSelectOrganizations = vi.fn();
|
||||||
render(
|
const { unmount } = render(
|
||||||
<Harness
|
<Harness
|
||||||
onConnected={onConnected}
|
onConnected={onConnected}
|
||||||
onSelectOrganizations={onSelectOrganizations}
|
onSelectOrganizations={onSelectOrganizations}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
return { onConnected, onSelectOrganizations, user: userEvent.setup() };
|
return {
|
||||||
|
onConnected,
|
||||||
|
onSelectOrganizations,
|
||||||
|
unmount,
|
||||||
|
user: userEvent.setup(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectButton = () =>
|
const connectButton = () =>
|
||||||
@@ -97,6 +115,9 @@ describe("AwsConnectStep", () => {
|
|||||||
useProviderWizardStore.getState().reset();
|
useProviderWizardStore.getState().reset();
|
||||||
addProvider.mockResolvedValue({ data: { id: "provider-1" } });
|
addProvider.mockResolvedValue({ data: { id: "provider-1" } });
|
||||||
addCredentialsProvider.mockResolvedValue({ data: { id: "secret-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(() => {
|
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", () => {
|
describe("when the step is left and reopened within the same wizard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useSession } from "next-auth/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -29,6 +35,7 @@ import {
|
|||||||
} from "@/components/shadcn/collapsible";
|
} from "@/components/shadcn/collapsible";
|
||||||
import { Form } from "@/components/shadcn/form";
|
import { Form } from "@/components/shadcn/form";
|
||||||
import { useFormServerErrors } from "@/hooks/use-form-server-errors";
|
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 { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
|
||||||
import { getAWSCredentialsTemplateLinks } from "@/lib/external-urls";
|
import { getAWSCredentialsTemplateLinks } from "@/lib/external-urls";
|
||||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||||
@@ -37,6 +44,7 @@ import {
|
|||||||
dispatchProviderFunnel,
|
dispatchProviderFunnel,
|
||||||
PROVIDER_FUNNEL_STEP,
|
PROVIDER_FUNNEL_STEP,
|
||||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||||
|
import { testProviderConnection } from "@/lib/provider-helpers";
|
||||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||||
import type { AWSCredentials, AWSCredentialsRole } from "@/types";
|
import type { AWSCredentials, AWSCredentialsRole } from "@/types";
|
||||||
import type { AwsConnectDraft } from "@/types/provider-wizard";
|
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__";
|
const UNIQUE_TOGETHER_ERROR_POINTER = "/data/attributes/__all__";
|
||||||
|
|
||||||
// What the user typed survives the step unmounting (organizations tab, a step
|
// 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 readDraft = () => useProviderWizardStore.getState().awsConnectDraft;
|
||||||
|
|
||||||
const initialMethod = (): AwsAccessMethod =>
|
const initialMethod = (): AwsAccessMethod =>
|
||||||
@@ -93,7 +101,7 @@ interface AwsConnectStepProps {
|
|||||||
onUiStateChange: (state: AwsConnectUiState) => void;
|
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({
|
export function AwsConnectStep({
|
||||||
formId,
|
formId,
|
||||||
onConnected,
|
onConnected,
|
||||||
@@ -189,6 +197,28 @@ interface UseAwsConnectSubmitOptions<T extends FieldValues> {
|
|||||||
onUiStateChange: (state: AwsConnectUiState) => void;
|
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<T extends FieldValues>({
|
function useAwsConnectSubmit<T extends FieldValues>({
|
||||||
form,
|
form,
|
||||||
method,
|
method,
|
||||||
@@ -205,25 +235,59 @@ function useAwsConnectSubmit<T extends FieldValues>({
|
|||||||
[UNIQUE_TOGETHER_ERROR_POINTER]: accountField,
|
[UNIQUE_TOGETHER_ERROR_POINTER]: accountField,
|
||||||
[ALIAS_ERROR_POINTER]: ProviderCredentialFields.PROVIDER_ALIAS,
|
[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<string | null>(null);
|
||||||
// A hook, not `form.formState.isValid` read inline: the React Compiler keys
|
// 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.
|
// its memo on the stable `form` object and would freeze a proxy read at false.
|
||||||
const { isSubmitting, isValid } = useFormState({ control: form.control });
|
const { isSubmitting, isValid } = useFormState({ control: form.control });
|
||||||
const canSubmit = isValid && accountResolved;
|
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.
|
// Same contract ConnectAccountForm uses: the wizard footer lives outside the step.
|
||||||
// Both callbacks must be stable setters, or this effect would loop.
|
// Both callbacks must be stable setters, or this effect would loop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onBusyChange(isSubmitting);
|
onBusyChange(isBusy);
|
||||||
onUiStateChange({
|
onUiStateChange({
|
||||||
showBack: true,
|
showBack: true,
|
||||||
showAction: true,
|
showAction: true,
|
||||||
actionLabel: isSubmitting ? "Connecting account..." : "Connect account",
|
actionLabel: resolveActionLabel({
|
||||||
actionDisabled: !canSubmit || isSubmitting,
|
isTesting,
|
||||||
isLoading: isSubmitting,
|
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({
|
const result = await connectAwsAccount({
|
||||||
method,
|
method,
|
||||||
values: { ...values, ...extraValues },
|
values: { ...values, ...extraValues },
|
||||||
@@ -241,8 +305,77 @@ function useAwsConnectSubmit<T extends FieldValues>({
|
|||||||
handleServerResponse({ errors: result.errors });
|
handleServerResponse({ errors: result.errors });
|
||||||
return;
|
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<HTMLDivElement>(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 (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="text-text-neutral-secondary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Loader2 aria-hidden className="size-4 animate-spin" />
|
||||||
|
Testing the connection. This usually takes a few seconds.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={alertRef}
|
||||||
|
role="alert"
|
||||||
|
className="border-border-error flex items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<CircleAlert
|
||||||
|
aria-hidden
|
||||||
|
className="text-text-error-primary size-5 shrink-0"
|
||||||
|
/>
|
||||||
|
<p className="text-text-error-primary min-w-0 text-sm break-words">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AwsRoleConnectForm({
|
function AwsRoleConnectForm({
|
||||||
@@ -284,7 +417,7 @@ function AwsRoleConnectForm({
|
|||||||
});
|
});
|
||||||
const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? "");
|
const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? "");
|
||||||
|
|
||||||
const onSubmit = useAwsConnectSubmit({
|
const { onSubmit, isTesting, connectionError } = useAwsConnectSubmit({
|
||||||
form,
|
form,
|
||||||
method: AWS_ACCESS_METHOD.ROLE,
|
method: AWS_ACCESS_METHOD.ROLE,
|
||||||
accountField: ProviderCredentialFields.ROLE_ARN,
|
accountField: ProviderCredentialFields.ROLE_ARN,
|
||||||
@@ -304,6 +437,8 @@ function AwsRoleConnectForm({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-6">
|
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||||
|
<ConnectionFeedback isTesting={isTesting} error={connectionError} />
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h4 className="text-sm font-semibold">1. Create the IAM role</h4>
|
<h4 className="text-sm font-semibold">1. Create the IAM role</h4>
|
||||||
<CredentialsRoleHelper
|
<CredentialsRoleHelper
|
||||||
@@ -384,7 +519,7 @@ function AwsKeysConnectForm({
|
|||||||
});
|
});
|
||||||
useDraftValues(form, "keysValues");
|
useDraftValues(form, "keysValues");
|
||||||
|
|
||||||
const onSubmit = useAwsConnectSubmit({
|
const { onSubmit, isTesting, connectionError } = useAwsConnectSubmit({
|
||||||
form,
|
form,
|
||||||
method: AWS_ACCESS_METHOD.CREDENTIALS,
|
method: AWS_ACCESS_METHOD.CREDENTIALS,
|
||||||
accountField: ProviderCredentialFields.PROVIDER_UID,
|
accountField: ProviderCredentialFields.PROVIDER_UID,
|
||||||
@@ -396,6 +531,8 @@ function AwsKeysConnectForm({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-4">
|
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<ConnectionFeedback isTesting={isTesting} error={connectionError} />
|
||||||
|
|
||||||
<WizardInputField
|
<WizardInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={ProviderCredentialFields.PROVIDER_UID}
|
name={ProviderCredentialFields.PROVIDER_UID}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("connectAwsAccount", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true, providerId: "provider-1" });
|
||||||
expect(formEntries(0, addProvider)).toEqual({
|
expect(formEntries(0, addProvider)).toEqual({
|
||||||
providerType: "aws",
|
providerType: "aws",
|
||||||
providerUid: "123456789012",
|
providerUid: "123456789012",
|
||||||
@@ -138,7 +138,7 @@ describe("connectAwsAccount", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true, providerId: "provider-1" });
|
||||||
expect(formEntries(0, addProvider)).toEqual({
|
expect(formEntries(0, addProvider)).toEqual({
|
||||||
providerType: "aws",
|
providerType: "aws",
|
||||||
providerUid: "210987654321",
|
providerUid: "210987654321",
|
||||||
@@ -262,7 +262,7 @@ describe("connectAwsAccount", () => {
|
|||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(first).toEqual({ ok: false, errors });
|
expect(first).toEqual({ ok: false, errors });
|
||||||
expect(second).toEqual({ ok: true });
|
expect(second).toEqual({ ok: true, providerId: "provider-1" });
|
||||||
expect(addProvider).toHaveBeenCalledOnce();
|
expect(addProvider).toHaveBeenCalledOnce();
|
||||||
expect(addCredentialsProvider).toHaveBeenCalledTimes(2);
|
expect(addCredentialsProvider).toHaveBeenCalledTimes(2);
|
||||||
expect(updateProvider).not.toHaveBeenCalled();
|
expect(updateProvider).not.toHaveBeenCalled();
|
||||||
@@ -285,7 +285,7 @@ describe("connectAwsAccount", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(second).toEqual({ ok: true });
|
expect(second).toEqual({ ok: true, providerId: "provider-1" });
|
||||||
expect(addProvider).toHaveBeenCalledOnce();
|
expect(addProvider).toHaveBeenCalledOnce();
|
||||||
expect(formEntries(0, updateProvider)).toEqual({
|
expect(formEntries(0, updateProvider)).toEqual({
|
||||||
providerId: "provider-1",
|
providerId: "provider-1",
|
||||||
@@ -318,7 +318,7 @@ describe("connectAwsAccount", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true, providerId: "provider-1" });
|
||||||
expect(addProvider).toHaveBeenCalledOnce();
|
expect(addProvider).toHaveBeenCalledOnce();
|
||||||
expect(addCredentialsProvider).toHaveBeenCalledOnce();
|
expect(addCredentialsProvider).toHaveBeenCalledOnce();
|
||||||
expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith(
|
expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface AwsConnectInput {
|
|||||||
|
|
||||||
interface AwsConnectSuccess {
|
interface AwsConnectSuccess {
|
||||||
ok: true;
|
ok: true;
|
||||||
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AwsConnectFailure {
|
interface AwsConnectFailure {
|
||||||
@@ -187,5 +188,5 @@ export async function connectAwsAccount(
|
|||||||
const store = useProviderWizardStore.getState();
|
const store = useProviderWizardStore.getState();
|
||||||
store.setSecretId(secret.id);
|
store.setSecretId(secret.id);
|
||||||
store.setVia(input.method);
|
store.setVia(input.method);
|
||||||
return { ok: true };
|
return { ok: true, providerId: provider.providerId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
|
|
||||||
interface ConnectStepProps {
|
interface ConnectStepProps {
|
||||||
onNext: () => void;
|
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;
|
onCredentialsSaved: () => void;
|
||||||
onSelectOrganizations: (orgType: OrgFlowType) => void;
|
onSelectOrganizations: (orgType: OrgFlowType) => void;
|
||||||
onFooterChange: (config: WizardFooterConfig) => void;
|
onFooterChange: (config: WizardFooterConfig) => void;
|
||||||
|
|||||||
@@ -46,16 +46,15 @@ const STEPS: StepConfig[] = [
|
|||||||
|
|
||||||
export const PROVIDER_WIZARD_STEPS = STEPS;
|
export const PROVIDER_WIZARD_STEPS = STEPS;
|
||||||
|
|
||||||
// AWS registers the account and its credentials in one step, so the wizard
|
// AWS registers the account, stores its credentials and tests the connection in
|
||||||
// skips straight from CONNECT to TEST; the stepper mirrors that.
|
// one step, so the wizard goes straight from CONNECT to LAUNCH.
|
||||||
export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [
|
export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [
|
||||||
{
|
{
|
||||||
label: "Link a Provider",
|
label: "Link a Provider",
|
||||||
description:
|
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,
|
icon: FolderGit2,
|
||||||
},
|
},
|
||||||
STEPS[2],
|
|
||||||
STEPS[3],
|
STEPS[3],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const addProviderTour = defineTour<AddProviderTourTarget>({
|
|||||||
autoAdvance: true,
|
autoAdvance: true,
|
||||||
title: "Add your account details",
|
title: "Add your account details",
|
||||||
description:
|
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.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user