test(ui): add tests for hooks, stores, and adapters

This commit is contained in:
alejandrobailo
2026-02-24 12:05:04 +01:00
parent 7437b8af8b
commit 8deb090ad5
6 changed files with 689 additions and 2 deletions
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import { APPLY_STATUS, DiscoveryResult } from "@/types/organizations";
import {
buildAccountLookup,
buildOrgTreeData,
getOuIdsForSelectedAccounts,
getSelectableAccountIds,
} from "./organizations.adapter";
const discoveryFixture: DiscoveryResult = {
roots: [
{
id: "r-root",
arn: "arn:aws:organizations::123:root/o-example/r-root",
name: "Root",
policy_types: [],
},
],
organizational_units: [
{
id: "ou-parent",
name: "Parent OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-parent",
parent_id: "r-root",
},
{
id: "ou-child",
name: "Child OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-child",
parent_id: "ou-parent",
},
],
accounts: [
{
id: "111111111111",
arn: "arn:aws:organizations::123:account/o-example/111111111111",
name: "App Account",
email: "app@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-child",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: "222222222222",
arn: "arn:aws:organizations::123:account/o-example/222222222222",
name: "Security Account",
email: "security@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-parent",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "manual_required",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["role_missing"],
},
},
{
id: "333333333333",
arn: "arn:aws:organizations::123:account/o-example/333333333333",
name: "Legacy Account",
email: "legacy@example.com",
status: "ACTIVE",
joined_method: "INVITED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
},
],
};
describe("buildOrgTreeData", () => {
it("builds nested tree structure and marks blocked accounts as disabled", () => {
// Given / When
const treeData = buildOrgTreeData(discoveryFixture);
// Then
expect(treeData).toHaveLength(2);
expect(treeData.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-parent", "333333333333"]),
);
const parentOuNode = treeData.find((node) => node.id === "ou-parent");
expect(parentOuNode).toBeDefined();
expect(parentOuNode?.children?.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-child", "222222222222"]),
);
const blockedAccount = parentOuNode?.children?.find(
(node) => node.id === "222222222222",
);
expect(blockedAccount?.disabled).toBe(true);
});
});
describe("getSelectableAccountIds", () => {
it("returns all accounts except explicitly blocked ones", () => {
const selectableIds = getSelectableAccountIds(discoveryFixture);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
it("excludes accounts with explicit non-ready status values", () => {
const discoveryWithUnexpectedStatus = {
...discoveryFixture,
accounts: [
...discoveryFixture.accounts,
{
id: "444444444444",
arn: "arn:aws:organizations::123:account/o-example/444444444444",
name: "Pending Account",
email: "pending@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: "pending" as unknown as APPLY_STATUS,
blocked_reasons: [],
},
},
],
} satisfies DiscoveryResult;
const selectableIds = getSelectableAccountIds(
discoveryWithUnexpectedStatus,
);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
});
describe("buildAccountLookup", () => {
it("creates a lookup map for all discovered accounts", () => {
const lookup = buildAccountLookup(discoveryFixture);
expect(lookup.get("111111111111")?.name).toBe("App Account");
expect(lookup.get("333333333333")?.name).toBe("Legacy Account");
expect(lookup.size).toBe(3);
});
});
describe("getOuIdsForSelectedAccounts", () => {
it("collects all ancestor OUs for selected accounts without duplicates", () => {
const ouIds = getOuIdsForSelectedAccounts(discoveryFixture, [
"111111111111",
"222222222222",
]);
expect(ouIds).toEqual(expect.arrayContaining(["ou-parent", "ou-child"]));
expect(ouIds.length).toBe(2);
});
});
@@ -0,0 +1,193 @@
import { act, renderHook } from "@testing-library/react";
import { createElement, type PropsWithChildren, StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { APPLY_STATUS, DISCOVERY_STATUS } from "@/types/organizations";
import { useOrgSetupSubmission } from "./use-org-setup-submission";
const organizationsActionsMock = vi.hoisted(() => ({
createOrganization: vi.fn(),
createOrganizationSecret: vi.fn(),
getDiscovery: vi.fn(),
listOrganizationsByExternalId: vi.fn(),
listOrganizationSecretsByOrganizationId: vi.fn(),
triggerDiscovery: vi.fn(),
updateOrganizationSecret: vi.fn(),
}));
vi.mock(
"@/actions/organizations/organizations",
() => organizationsActionsMock,
);
function StrictModeWrapper({ children }: PropsWithChildren) {
return createElement(StrictMode, null, children);
}
describe("useOrgSetupSubmission", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
for (const mockFn of Object.values(organizationsActionsMock)) {
mockFn.mockReset();
}
});
it("completes the setup chain and stores selectable accounts", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
const discoveryResult = {
roots: [
{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] },
],
organizational_units: [],
accounts: [
{
id: "111111111111",
name: "Account One",
arn: "arn:aws:organizations::111111111111:account/o-123/111111111111",
email: "one@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: "222222222222",
name: "Account Two",
arn: "arn:aws:organizations::222222222222:account/o-123/222222222222",
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["Already linked"],
},
},
],
};
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
data: { id: "org-1" },
});
organizationsActionsMock.listOrganizationSecretsByOrganizationId.mockResolvedValue(
{
data: [],
},
);
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
data: { id: "secret-1" },
});
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
data: { id: "discovery-1" },
});
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.SUCCEEDED,
result: discoveryResult,
},
},
});
const { result } = renderHook(
() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
{ wrapper: StrictModeWrapper },
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then
expect(onNext).toHaveBeenCalledTimes(1);
expect(setFieldError).not.toHaveBeenCalled();
const state = useOrgSetupStore.getState();
expect(state.organizationId).toBe("org-1");
expect(state.organizationExternalId).toBe("o-abc123def4");
expect(state.discoveryId).toBe("discovery-1");
expect(state.selectedAccountIds).toEqual(["111111111111"]);
expect(state.selectableAccountIds).toEqual(["111111111111"]);
});
it("maps external_id server errors to awsOrgId field errors", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
errors: [
{
detail: "Organization with this external_id already exists.",
source: { pointer: "/data/attributes/external_id" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then
expect(setFieldError).toHaveBeenCalledWith(
"awsOrgId",
"Organization with this external_id already exists.",
);
expect(result.current.apiError).toBe(
"Organization with this external_id already exists.",
);
expect(onNext).not.toHaveBeenCalled();
expect(
organizationsActionsMock.createOrganizationSecret,
).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
import { act, render, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { OrgLaunchScan } from "./org-launch-scan";
const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({
launchOrganizationScansMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/scans/scans", () => ({
launchOrganizationScans: launchOrganizationScansMock,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
useToast: () => ({
toast: toastMock,
}),
}));
describe("OrgLaunchScan", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
launchOrganizationScansMock.mockReset();
pushMock.mockReset();
toastMock.mockReset();
useOrgSetupStore.getState().reset();
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Organization", "o-abc123def4");
useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]);
});
it("shows a success toast with an action linking to scans", async () => {
// Given
launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
const onFooterChange = vi.fn();
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
// When
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
footerConfig.onAction?.();
});
// Then
await waitFor(() => {
expect(toastMock).toHaveBeenCalledTimes(1);
});
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.title).toBe("Scan Launched");
expect(toastPayload.action).toBeDefined();
expect(toastPayload.action.props.children.props.href).toBe("/scans");
});
});
@@ -0,0 +1,188 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ORG_WIZARD_STEP } from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
status: "unauthenticated",
}),
}));
describe("useProviderWizardController", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
pushMock.mockReset();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
it("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
// When
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
initialData: {
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "production",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
},
}),
);
// Then
await waitFor(() => {
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
});
expect(result.current.modalTitle).toBe("Update Provider Credentials");
expect(result.current.isProviderFlow).toBe(true);
expect(result.current.docsLink).toBe(
"https://goto.prowler.com/provider-aws",
);
const state = useProviderWizardStore.getState();
expect(state.providerId).toBe("provider-1");
expect(state.providerType).toBe("aws");
expect(state.providerUid).toBe("111111111111");
expect(state.providerAlias).toBe("production");
expect(state.secretId).toBe("secret-1");
expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE);
});
it("switches into and out of organizations flow", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.openOrganizationsFlow();
});
// Then
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.isProviderFlow).toBe(false);
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP);
// When
act(() => {
result.current.backToProviderFlow();
});
// Then
expect(result.current.wizardVariant).toBe("provider");
expect(result.current.isProviderFlow).toBe(true);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
});
it("moves to launch step after a successful connection test in add mode", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
result.current.handleTestSuccess();
});
// Then
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
});
it("closes and navigates when launch footer action is triggered", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
});
const { resolvedFooterConfig } = result.current;
act(() => {
resolvedFooterConfig.onAction?.();
});
// Then
expect(pushMock).toHaveBeenCalledWith("/scans");
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
});
it("does not reset organizations step when org store updates while modal is open", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
act(() => {
result.current.openOrganizationsFlow();
result.current.setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
});
// When
act(() => {
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("disc-1", {
roots: [],
organizational_units: [],
accounts: [],
});
});
// Then
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useOrgSetupStore } from "./store";
describe("useOrgSetupStore", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
});
it("persists organization wizard state in sessionStorage", () => {
// Given
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("discovery-1", {
roots: [],
organizational_units: [],
accounts: [],
});
useOrgSetupStore
.getState()
.setSelectedAccountIds(["111111111111", "222222222222"]);
// When
const persistedValue = sessionStorage.getItem("org-setup-store");
// Then
expect(persistedValue).toBeTruthy();
expect(localStorage.getItem("org-setup-store")).toBeNull();
});
});
+23 -2
View File
@@ -1,13 +1,17 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { useProviderWizardStore } from "./store";
describe("useProviderWizardStore", () => {
it("stores provider identity and mode, then resets to defaults", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
});
it("stores provider identity and mode, then resets to defaults", () => {
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
@@ -38,4 +42,21 @@ describe("useProviderWizardStore", () => {
expect(afterReset.secretId).toBeNull();
expect(afterReset.mode).toBe(PROVIDER_WIZARD_MODE.ADD);
});
it("persists provider wizard state in sessionStorage", () => {
// Given
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "123456789012",
alias: "prod-account",
});
// When
const persistedValue = sessionStorage.getItem("provider-wizard-store");
// Then
expect(persistedValue).toBeTruthy();
expect(localStorage.getItem("provider-wizard-store")).toBeNull();
});
});