fix(ui): harden AWS onboarding deployment flows

This commit is contained in:
alejandrobailo committed 2026-07-16 10:04:00 +02:00
1 parent 89013c561f
commit 0df667264b
7 files changed
+644 -119

No files matched your search

@@ -0,0 +1,244 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import type { ProviderProps } from "@/types/providers";
import { S3IntegrationForm } from "./s3-integration-form";
const { createIntegrationMock, toastMock, updateIntegrationMock } = vi.hoisted(
() => ({
createIntegrationMock: vi.fn(),
toastMock: vi.fn(),
updateIntegrationMock: vi.fn(),
}),
);
vi.mock("@/actions/integrations", () => ({
createIntegration: createIntegrationMock,
updateIntegration: updateIntegrationMock,
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: {
tenantId: "tenant-id",
},
}),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({
toast: toastMock,
}),
}));
interface MockEnhancedMultiSelectProps {
onValueChange: (values: string[]) => void;
options: Array<{ value: string }>;
}
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: ({
onValueChange,
options,
}: MockEnhancedMultiSelectProps) => (
<button
type="button"
onClick={() => onValueChange(options[0] ? [options[0].value] : [])}
>
Select first provider
</button>
),
}));
vi.mock(
"@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form",
() => ({
AWSRoleCredentialsForm: ({
templateLinks,
}: {
templateLinks: { cloudformationQuickLink: string };
}) => (
<output aria-label="CloudFormation quick link">
{templateLinks.cloudformationQuickLink}
</output>
),
}),
);
vi.mock("@/lib", () => ({
getAWSCredentialsTemplateLinks: (
_externalId: string,
_bucketName: string,
_integrationType: string,
bucketAccountId?: string,
) => ({
cloudformation: "https://example.com/cloudformation",
terraform: "https://example.com/terraform",
cloudformationQuickLink: `https://example.com/quick-create?bucketAccountId=${bucketAccountId ?? ""}`,
}),
}));
function createProvider(
provider: ProviderProps["attributes"]["provider"],
uid: string,
): ProviderProps {
return {
id: `${provider}-provider`,
type: "providers",
attributes: {
provider,
is_dynamic: false,
uid,
alias: `${provider} provider`,
status: "completed",
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-07-16T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-07-16T00:00:00Z",
updated_at: "2026-07-16T00:00:00Z",
created_by: {
object: "users",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
};
}
function renderS3IntegrationForm(
props?: Partial<ComponentProps<typeof S3IntegrationForm>>,
) {
return render(
<S3IntegrationForm
providers={[]}
onSuccess={vi.fn()}
onCancel={vi.fn()}
{...props}
/>,
);
}
const integration: IntegrationProps = {
type: "integrations",
id: "integration-1",
attributes: {
inserted_at: "2026-07-16T00:00:00Z",
updated_at: "2026-07-16T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-07-16T00:00:00Z",
integration_type: "amazon_s3",
configuration: {
bucket_name: "prowler-reports",
output_directory: "output",
},
},
relationships: {
providers: {
data: [{ type: "providers", id: "aws-provider" }],
},
},
links: {
self: "/integrations/integration-1",
},
};
describe("S3IntegrationForm", () => {
beforeEach(() => {
createIntegrationMock.mockReset();
toastMock.mockReset();
updateIntegrationMock.mockReset();
});
it("should require the bucket owner account ID when it cannot derive one", async () => {
// Given
const user = userEvent.setup();
renderS3IntegrationForm({
providers: [createProvider("azure", "subscription-id")],
});
// When
await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports");
await user.click(screen.getByRole("button", { name: "Next" }));
// Then
expect(
await screen.findByText(
"Bucket owner account ID is required when no AWS account is selected",
),
).toBeVisible();
expect(
screen.queryByLabelText("CloudFormation quick link"),
).not.toBeInTheDocument();
});
it("should derive the bucket owner account ID from the selected AWS provider", async () => {
// Given
const user = userEvent.setup();
renderS3IntegrationForm({
providers: [createProvider("aws", "123456789012")],
});
// When
await user.click(
screen.getByRole("button", { name: "Select first provider" }),
);
await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports");
await user.click(screen.getByRole("button", { name: "Next" }));
// Then
expect(
await screen.findByLabelText("CloudFormation quick link"),
).toHaveTextContent("bucketAccountId=123456789012");
});
it("should not show a bucket account field that configuration updates cannot persist", () => {
// When
renderS3IntegrationForm({
integration,
providers: [createProvider("aws", "123456789012")],
editMode: "configuration",
});
// Then
expect(
screen.queryByLabelText(/Bucket owner account ID/i),
).not.toBeInTheDocument();
});
it("should allow changing the bucket owner account for credential updates", () => {
// When
renderS3IntegrationForm({
integration,
providers: [createProvider("aws", "123456789012")],
editMode: "credentials",
});
// Then
expect(
screen.getByLabelText(/Bucket owner account ID/i),
).toBeInTheDocument();
});
});
@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import type { Control } from "react-hook-form";
import { useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import {
@@ -25,13 +26,13 @@ import {
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
import type { AWSCredentialsRole } from "@/types";
import type { IntegrationProps } from "@/types/integrations";
import {
editS3IntegrationFormSchema,
IntegrationProps,
s3IntegrationFormSchema,
} from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
import type { ProviderProps } from "@/types/providers";
interface S3IntegrationFormProps {
integration?: IntegrationProps | null;
@@ -41,6 +42,25 @@ interface S3IntegrationFormProps {
editMode?: "configuration" | "credentials" | null; // null means creating new
}
const getSelectedAWSAccountId = (
selectedProviderIds: string[],
providers: ProviderProps[],
): string => {
for (const providerId of selectedProviderIds) {
const provider = providers.find(({ id }) => id === providerId);
const uid = provider?.attributes.uid;
if (
provider?.attributes.provider === "aws" &&
uid &&
/^\d{12}$/.test(uid)
) {
return uid;
}
}
return "";
};
export const S3IntegrationForm = ({
integration,
providers,
@@ -95,26 +115,14 @@ export const S3IntegrationForm = ({
});
const isLoading = form.formState.isSubmitting;
// Derives the AWS Account ID that owns the S3 bucket from the selected
// provider(s). For AWS providers the uid is the 12-digit account id. This is
// the common case (bucket lives in a scanned account); cross-account buckets
// can still be overridden via the "Bucket owner account ID" field.
const deriveBucketAccountId = (): string => {
const selectedIds = form.getValues("providers") || [];
for (const id of selectedIds) {
const provider = providers.find((p) => p.id === id);
const uid = provider?.attributes.uid;
if (
provider?.attributes.provider === "aws" &&
uid &&
/^\d{12}$/.test(uid)
) {
return uid;
}
}
return "";
};
const selectedProviderIds = form.watch("providers") || [];
const bucketAccountIdOverride = form.watch("bucket_account_id")?.trim() || "";
const derivedBucketAccountId = getSelectedAWSAccountId(
selectedProviderIds,
providers,
);
const resolvedBucketAccountId =
bucketAccountIdOverride || derivedBucketAccountId;
const handleNext = async (e: React.FormEvent) => {
e.preventDefault();
@@ -140,9 +148,20 @@ export const S3IntegrationForm = ({
const isValid = stepFields.length === 0 || (await form.trigger(stepFields));
if (isValid) {
setCurrentStep(1);
if (!isValid) {
return;
}
if (!resolvedBucketAccountId) {
form.setError("bucket_account_id", {
message:
"Bucket owner account ID is required when no AWS account is selected",
});
return;
}
form.clearErrors("bucket_account_id");
setCurrentStep(1);
};
const handleBack = () => {
@@ -283,30 +302,55 @@ export const S3IntegrationForm = ({
}
};
const renderBucketAccountIdField = () => (
<div className="flex flex-col gap-1">
<CustomInput
control={form.control}
name="bucket_account_id"
type="text"
label="Bucket owner account ID"
labelPlacement="inside"
placeholder={
derivedBucketAccountId
? `Defaults to ${derivedBucketAccountId}`
: "12-digit AWS account ID"
}
variant="bordered"
isRequired={false}
/>
<p className="text-text-neutral-tertiary text-xs">
{derivedBucketAccountId
? `Leave empty to use selected AWS account ${derivedBucketAccountId}, or enter another bucket owner account ID.`
: "Required because the selected provider does not identify the AWS account that owns the bucket."}
</p>
</div>
);
const renderStepContent = () => {
// If editing credentials, show only credentials form
if (isEditingCredentials || currentStep === 1) {
const bucketName = form.getValues("bucket_name") || "";
const bucketAccountId =
form.getValues("bucket_account_id") || deriveBucketAccountId();
const externalId =
form.getValues("external_id") || session?.tenantId || "";
const templateLinks = getAWSCredentialsTemplateLinks(
externalId,
bucketName,
"amazon_s3",
bucketAccountId,
resolvedBucketAccountId,
);
return (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="integrations"
integrationType="amazon_s3"
/>
<div className="flex flex-col gap-4">
{isEditingCredentials && renderBucketAccountIdField()}
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="integrations"
integrationType="amazon_s3"
/>
</div>
);
}
@@ -378,23 +422,7 @@ export const S3IntegrationForm = ({
isRequired
/>
<div className="flex flex-col gap-1">
<CustomInput
control={form.control}
name="bucket_account_id"
type="text"
label="Bucket owner account ID (optional)"
labelPlacement="inside"
placeholder="Defaults to the selected account"
variant="bordered"
isRequired={false}
/>
<p className="text-text-neutral-tertiary text-xs">
AWS account ID that owns the bucket. Leave empty to use the
selected account, or set it if the bucket lives in a different
account.
</p>
</div>
{!isEditingConfig && renderBucketAccountIdField()}
</div>
</>
);