mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
868 lines
26 KiB
TypeScript
868 lines
26 KiB
TypeScript
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";
|
|
|
|
import {
|
|
PROVIDER_FUNNEL_EVENT,
|
|
type ProviderFunnelDetail,
|
|
} from "@/lib/provider-funnel/provider-funnel-events";
|
|
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
|
import {
|
|
CONNECTION_CHECK_STATUS,
|
|
type ConnectionCheckStatus,
|
|
} from "@/types/providers";
|
|
|
|
import { AwsConnectStep } from "./aws-connect-step";
|
|
import type { AwsConnectUiState } from "./types";
|
|
|
|
const {
|
|
addProvider,
|
|
addCredentialsProvider,
|
|
updateProvider,
|
|
updateCredentialsProvider,
|
|
testProviderConnection,
|
|
openCloudUpgradeMock,
|
|
endActiveTour,
|
|
getInvitationRoles,
|
|
sendInvite,
|
|
session,
|
|
} = vi.hoisted(() => ({
|
|
addProvider: vi.fn(),
|
|
addCredentialsProvider: vi.fn(),
|
|
updateProvider: vi.fn(),
|
|
updateCredentialsProvider: vi.fn(),
|
|
testProviderConnection: vi.fn(),
|
|
openCloudUpgradeMock: vi.fn(),
|
|
endActiveTour: vi.fn(),
|
|
getInvitationRoles: vi.fn(),
|
|
sendInvite: vi.fn(),
|
|
// Mutable: only the permissions differ between suites.
|
|
session: {
|
|
data: { tenantId: "tenant-abc" } as {
|
|
tenantId: string;
|
|
user?: { permissions: Record<string, boolean> };
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("next-auth/react", () => ({
|
|
useSession: () => ({ data: session.data, status: "authenticated" }),
|
|
}));
|
|
vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour }));
|
|
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
|
|
vi.mock("@/actions/invitations/invitation", () => ({ sendInvite }));
|
|
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: {
|
|
openCloudUpgrade: typeof openCloudUpgradeMock;
|
|
}) => unknown,
|
|
) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
|
|
}));
|
|
|
|
const FORM_ID = "aws-connect-test-form";
|
|
const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan";
|
|
|
|
// Stands in for the wizard footer: the step only publishes its UI state.
|
|
function Harness({
|
|
onConnected,
|
|
onSelectOrganizations,
|
|
}: {
|
|
onConnected: () => void;
|
|
onSelectOrganizations: () => void;
|
|
}) {
|
|
const [uiState, setUiState] = useState<AwsConnectUiState | null>(null);
|
|
return (
|
|
<>
|
|
<AwsConnectStep
|
|
formId={FORM_ID}
|
|
onConnected={onConnected}
|
|
onSelectOrganizations={onSelectOrganizations}
|
|
onUiStateChange={setUiState}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
form={FORM_ID}
|
|
disabled={uiState?.actionDisabled ?? true}
|
|
>
|
|
{uiState?.actionLabel ?? "Connect account"}
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function renderStep() {
|
|
const onConnected = vi.fn();
|
|
const onSelectOrganizations = vi.fn();
|
|
const { unmount } = render(
|
|
<Harness
|
|
onConnected={onConnected}
|
|
onSelectOrganizations={onSelectOrganizations}
|
|
/>,
|
|
);
|
|
return {
|
|
onConnected,
|
|
onSelectOrganizations,
|
|
unmount,
|
|
user: userEvent.setup(),
|
|
};
|
|
}
|
|
|
|
const connectButton = () =>
|
|
screen.getByRole("button", { name: "Connect account" });
|
|
|
|
describe("AwsConnectStep", () => {
|
|
const funnelSignals: ProviderFunnelDetail[] = [];
|
|
const recordFunnelSignal: EventListener = (event) => {
|
|
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
funnelSignals.length = 0;
|
|
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
|
vi.clearAllMocks();
|
|
sessionStorage.clear();
|
|
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({
|
|
status: CONNECTION_CHECK_STATUS.SUCCESS,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
|
vi.unstubAllEnvs();
|
|
session.data = { tenantId: "tenant-abc" };
|
|
});
|
|
|
|
describe("in Prowler Cloud", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
|
});
|
|
|
|
it("creates the role from the shared stack and connects with just its ARN", async () => {
|
|
// Given
|
|
const { onConnected, user } = renderStep();
|
|
|
|
// Then: the button opens the shared template with the External ID filled in;
|
|
// the AccountId parameter defaults to Prowler Cloud's account there.
|
|
const quickCreate = screen.getByRole("link", {
|
|
name: /Create the IAM role in AWS/i,
|
|
});
|
|
expect(quickCreate).toHaveAttribute(
|
|
"href",
|
|
expect.stringContaining("prowler-scan-role.yml"),
|
|
);
|
|
expect(quickCreate).toHaveAttribute(
|
|
"href",
|
|
expect.stringContaining("param_ExternalId=tenant-abc"),
|
|
);
|
|
expect(connectButton()).toBeDisabled();
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Role ARN/ }),
|
|
ROLE_ARN,
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
await screen.findByText(/Account 123456789012 will be added/),
|
|
).toBeVisible();
|
|
await waitFor(() => expect(connectButton()).toBeEnabled());
|
|
|
|
// When
|
|
await user.click(connectButton());
|
|
|
|
// Then
|
|
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
|
|
const secret = Object.fromEntries(
|
|
(addCredentialsProvider.mock.calls[0][0] as FormData).entries(),
|
|
);
|
|
expect(secret).toMatchObject({
|
|
providerId: "provider-1",
|
|
role_arn: ROLE_ARN,
|
|
external_id: "tenant-abc",
|
|
credentials_type: "aws-sdk-default",
|
|
});
|
|
expect(funnelSignals).toContainEqual({
|
|
step: "account_submitted",
|
|
providerType: "aws",
|
|
via: "role",
|
|
outcome: "success",
|
|
});
|
|
});
|
|
|
|
it("shows an account the API already knows on the ARN field and stays on the step", async () => {
|
|
// Given
|
|
addProvider.mockResolvedValueOnce({
|
|
errors: [
|
|
{
|
|
detail: "Provider with this uid already exists.",
|
|
source: { pointer: "/data/attributes/uid" },
|
|
},
|
|
],
|
|
});
|
|
const { onConnected, user } = renderStep();
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Role ARN/ }),
|
|
ROLE_ARN,
|
|
);
|
|
await waitFor(() => expect(connectButton()).toBeEnabled());
|
|
|
|
// When
|
|
await user.click(connectButton());
|
|
|
|
// Then
|
|
expect(
|
|
await screen.findByText("Provider with this uid already exists."),
|
|
).toBeVisible();
|
|
expect(onConnected).not.toHaveBeenCalled();
|
|
expect(funnelSignals).toContainEqual({
|
|
step: "account_submitted",
|
|
providerType: "aws",
|
|
via: "role",
|
|
outcome: "error",
|
|
});
|
|
});
|
|
|
|
it("hands the whole-organization choice to the organizations flow", async () => {
|
|
// Given
|
|
const { onSelectOrganizations, user } = renderStep();
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("tab", { name: /Full AWS Organization/ }),
|
|
);
|
|
|
|
// Then
|
|
expect(onSelectOrganizations).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("connects with access keys and the typed account id", async () => {
|
|
// Given
|
|
const { onConnected, user } = renderStep();
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("radio", { name: /Static access keys/ }),
|
|
);
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Account ID/ }),
|
|
"210987654321",
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Enter the AWS Access Key ID"),
|
|
"AKIAEXAMPLE",
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
|
|
"secret-value",
|
|
);
|
|
await waitFor(() => expect(connectButton()).toBeEnabled());
|
|
await user.click(connectButton());
|
|
|
|
// Then
|
|
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
|
|
const provider = Object.fromEntries(
|
|
(addProvider.mock.calls[0][0] as FormData).entries(),
|
|
);
|
|
expect(provider).toEqual({
|
|
providerType: "aws",
|
|
providerUid: "210987654321",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("with access keys, when the API refuses the account", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
|
});
|
|
|
|
it("shows the refusal on the Account ID field and stays on the step", async () => {
|
|
// Given
|
|
addProvider.mockResolvedValueOnce({
|
|
errors: [
|
|
{
|
|
detail: "Provider with this uid already exists.",
|
|
source: { pointer: "/data/attributes/uid" },
|
|
},
|
|
],
|
|
});
|
|
const { onConnected, user } = renderStep();
|
|
await user.click(
|
|
screen.getByRole("radio", { name: /Static access keys/ }),
|
|
);
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Account ID/ }),
|
|
"210987654321",
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Enter the AWS Access Key ID"),
|
|
"AKIAEXAMPLE",
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
|
|
"secret-value",
|
|
);
|
|
await waitFor(() => expect(connectButton()).toBeEnabled());
|
|
|
|
// When
|
|
await user.click(connectButton());
|
|
|
|
// Then
|
|
expect(
|
|
await screen.findByText("Provider with this uid already exists."),
|
|
).toBeVisible();
|
|
// The field wrapper carries the invalid state for the Account ID input.
|
|
expect(
|
|
screen
|
|
.getByRole("textbox", { name: /Account ID/ })
|
|
.closest("[aria-invalid]"),
|
|
).toHaveAttribute("aria-invalid", "true");
|
|
expect(onConnected).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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: {
|
|
status: ConnectionCheckStatus;
|
|
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({ status: CONNECTION_CHECK_STATUS.SUCCESS, 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: {
|
|
status: ConnectionCheckStatus;
|
|
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({ status: CONNECTION_CHECK_STATUS.SUCCESS, 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({
|
|
status: CONNECTION_CHECK_STATUS.FAILED,
|
|
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({
|
|
status: CONNECTION_CHECK_STATUS.FAILED,
|
|
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();
|
|
});
|
|
|
|
it("shows a neutral message, not a failure, when the check is still pending", async () => {
|
|
// Given: the wait was exhausted with no confirmed outcome (the backend
|
|
// check is genuinely still running past the wait).
|
|
testProviderConnection.mockResolvedValue({
|
|
status: CONNECTION_CHECK_STATUS.PENDING,
|
|
error:
|
|
"The connection test is still running. Refresh in a moment to see the result.",
|
|
});
|
|
|
|
// When
|
|
const { onConnected } = await submitRole();
|
|
|
|
// Then: announced neutrally, not as an alert, and the account stays
|
|
// registered rather than reporting a failure the backend never gave.
|
|
expect(await screen.findByRole("status")).toHaveTextContent(
|
|
/still running/i,
|
|
);
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
// Nor does it advance: the outcome is still unknown.
|
|
expect(onConnected).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("button", { name: "Check again" })).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({
|
|
status: CONNECTION_CHECK_STATUS.FAILED,
|
|
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({
|
|
status: CONNECTION_CHECK_STATUS.FAILED,
|
|
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({
|
|
status: CONNECTION_CHECK_STATUS.FAILED,
|
|
error: "Denied.",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: CONNECTION_CHECK_STATUS.SUCCESS,
|
|
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");
|
|
});
|
|
|
|
it("keeps what was typed, including the chosen access method", async () => {
|
|
// Given
|
|
const onConnected = vi.fn();
|
|
const onSelectOrganizations = vi.fn();
|
|
const user = userEvent.setup();
|
|
const { unmount } = render(
|
|
<Harness
|
|
onConnected={onConnected}
|
|
onSelectOrganizations={onSelectOrganizations}
|
|
/>,
|
|
);
|
|
await user.click(
|
|
screen.getByRole("radio", { name: /Static access keys/ }),
|
|
);
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Account ID/ }),
|
|
"210987654321",
|
|
);
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Provider alias/ }),
|
|
"Staging",
|
|
);
|
|
|
|
// When: the organizations tab or the connection test unmounts the step.
|
|
unmount();
|
|
render(
|
|
<Harness
|
|
onConnected={onConnected}
|
|
onSelectOrganizations={onSelectOrganizations}
|
|
/>,
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByRole("radio", { name: /Static access keys/ }),
|
|
).toHaveAttribute("aria-checked", "true");
|
|
expect(screen.getByRole("textbox", { name: /Account ID/ })).toHaveValue(
|
|
"210987654321",
|
|
);
|
|
expect(
|
|
screen.getByRole("textbox", { name: /Provider alias/ }),
|
|
).toHaveValue("Staging");
|
|
});
|
|
|
|
it("starts blank again once the wizard is reset", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
const { unmount } = render(
|
|
<Harness onConnected={vi.fn()} onSelectOrganizations={vi.fn()} />,
|
|
);
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Role ARN/ }),
|
|
ROLE_ARN,
|
|
);
|
|
unmount();
|
|
|
|
// When
|
|
useProviderWizardStore.getState().reset();
|
|
render(<Harness onConnected={vi.fn()} onSelectOrganizations={vi.fn()} />);
|
|
|
|
// Then
|
|
expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue("");
|
|
});
|
|
});
|
|
|
|
describe("in Prowler Cloud, role creation", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
|
});
|
|
|
|
it("leads with the one-click stack and keeps the other templates behind a toggle", async () => {
|
|
// Given
|
|
const { user } = renderStep();
|
|
|
|
// Then
|
|
expect(
|
|
screen.queryByRole("link", { name: /CloudFormation Template/i }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("link", { name: /Terraform Code/i }),
|
|
).not.toBeInTheDocument();
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("button", { name: /Other ways to create the role/i }),
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByRole("link", { name: /CloudFormation Template/i }),
|
|
).toHaveAttribute("href", expect.stringContaining("prowler-scan-role"));
|
|
expect(
|
|
screen.getByRole("link", { name: /Terraform Code/i }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("never asks which credentials assume the role: Prowler Cloud does", async () => {
|
|
// Given
|
|
const { user } = renderStep();
|
|
await user.click(
|
|
screen.getByRole("button", { name: /Advanced options/i }),
|
|
);
|
|
|
|
// Then
|
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByPlaceholderText("Enter the AWS Access Key ID"),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByPlaceholderText("Enter the role session name"),
|
|
).toBeVisible();
|
|
});
|
|
});
|
|
|
|
describe("in a self-hosted deployment", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
|
});
|
|
|
|
it("offers the same one-click role setup, on the shared template", async () => {
|
|
// Given
|
|
const { user } = renderStep();
|
|
|
|
// Then: the template keeps the AccountId parameter self-hosted users must edit.
|
|
expect(
|
|
screen.getByRole("link", { name: /Create the IAM role in AWS/i }),
|
|
).toHaveAttribute(
|
|
"href",
|
|
expect.stringContaining("prowler-scan-role.yml"),
|
|
);
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("button", { name: /Advanced options/i }),
|
|
);
|
|
|
|
// Then: keys belong to the "Static access keys" method, never to the role one.
|
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByPlaceholderText("Enter the AWS Access Key ID"),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByPlaceholderText("Enter the role session name"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("assumes the role with the credentials of the host running Prowler", async () => {
|
|
// Given
|
|
const { onConnected, user } = renderStep();
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Role ARN/ }),
|
|
ROLE_ARN,
|
|
);
|
|
await screen.findByText(/Account 123456789012 will be added/);
|
|
await waitFor(() => expect(connectButton()).toBeEnabled());
|
|
await user.click(connectButton());
|
|
|
|
// Then
|
|
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
|
|
const secret = Object.fromEntries(
|
|
(addCredentialsProvider.mock.calls[0][0] as FormData).entries(),
|
|
);
|
|
expect(secret).toMatchObject({
|
|
role_arn: ROLE_ARN,
|
|
credentials_type: "aws-sdk-default",
|
|
});
|
|
expect(secret).not.toHaveProperty("aws_access_key_id");
|
|
});
|
|
});
|
|
|
|
describe("inviting a teammate who can connect the account", () => {
|
|
const inviteRadio = () =>
|
|
screen.queryByRole("radio", { name: /invite a teammate/i });
|
|
|
|
beforeEach(() => {
|
|
session.data = {
|
|
tenantId: "tenant-abc",
|
|
user: { permissions: { manage_account: true } },
|
|
};
|
|
getInvitationRoles.mockResolvedValue([
|
|
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
|
|
]);
|
|
});
|
|
|
|
it("is not offered to a user who cannot invite", () => {
|
|
// Given: no `manage_account`, so the API would refuse the invitation.
|
|
session.data = { tenantId: "tenant-abc" };
|
|
|
|
// When
|
|
renderStep();
|
|
|
|
// Then
|
|
expect(inviteRadio()).not.toBeInTheDocument();
|
|
expect(screen.getByRole("radio", { name: /IAM Role/ })).toBeChecked();
|
|
});
|
|
|
|
it("swaps the AWS form for the invitation and signals the choice once", async () => {
|
|
// Given
|
|
const { user } = renderStep();
|
|
|
|
// When
|
|
await user.click(inviteRadio()!);
|
|
await user.click(inviteRadio()!);
|
|
|
|
// Then: the teammate form takes the step and the footer, the tour steps aside.
|
|
expect(inviteRadio()).toBeChecked();
|
|
expect(
|
|
screen.queryByRole("textbox", { name: /Role ARN/ }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
await screen.findByRole("textbox", { name: /Teammate email/ }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: "Send invitation" }),
|
|
).toBeInTheDocument();
|
|
expect(endActiveTour).toHaveBeenCalled();
|
|
expect(
|
|
funnelSignals.filter((signal) => signal.step === "method_selected"),
|
|
).toEqual([
|
|
{
|
|
step: "method_selected",
|
|
providerType: "aws",
|
|
method: "invite_teammate",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("comes back to the IAM Role form with what was typed", async () => {
|
|
// Given
|
|
const { user } = renderStep();
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /Role ARN/ }),
|
|
ROLE_ARN,
|
|
);
|
|
await user.click(inviteRadio()!);
|
|
await screen.findByRole("textbox", { name: /Teammate email/ });
|
|
|
|
// When
|
|
await user.click(screen.getByRole("radio", { name: /IAM Role/ }));
|
|
|
|
// Then
|
|
expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue(
|
|
ROLE_ARN,
|
|
);
|
|
expect(inviteRadio()).not.toBeChecked();
|
|
});
|
|
|
|
it("restores the invitation panel when the step is reopened", async () => {
|
|
// Given: the user left for the organizations tab and came back.
|
|
const { user, unmount } = renderStep();
|
|
await user.click(inviteRadio()!);
|
|
await screen.findByRole("textbox", { name: /Teammate email/ });
|
|
unmount();
|
|
|
|
// When
|
|
renderStep();
|
|
|
|
// Then
|
|
expect(inviteRadio()).toBeChecked();
|
|
expect(
|
|
await screen.findByRole("textbox", { name: /Teammate email/ }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|