test(ui): consolidate the providers page integration suites (#12383)

This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-11 18:50:10 +02:00
committed by GitHub
parent 8bfca81e4b
commit 6d7bc8a86e
4 changed files with 785 additions and 779 deletions
@@ -1,481 +0,0 @@
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
buildGcpDiscoveryResult,
DISCOVERY_STATUS_VALUE,
GCP_BLOCKED_FOLDER,
GCP_BLOCKED_FOLDER_NAME,
GCP_BLOCKED_FOLDER_PROJECT,
GCP_CREATED_PROVIDER_IDS,
GCP_EMPTY_FOLDER,
GCP_EMPTY_FOLDER_NAME,
GCP_LONG_PROJECT_ID,
GCP_ORG_ID,
gcpOnboardingFixture,
mixedHierarchyFixture,
type OrgFixture,
} from "@/__tests__/msw/handlers/organizations.fixtures";
import { ORGANIZATION_TYPE } from "@/types/organizations";
import { ProvidersPageHarness } from "./providers-page.harness";
// The GCP Organization flow end to end: method fork, setup, selection tree, apply,
// and the shared safety UX. Discovery timeout/keep-waiting/resume live in the
// submission unit tests instead, since they need fake timers.
const VALID_SA_KEY = JSON.stringify({
type: "service_account",
project_id: "prowler-scan",
private_key_id: "abcdef",
client_email: "prowler@prowler-scan.iam.gserviceaccount.com",
});
/** The GCP docs tutorial the wizard links to during the org flow. */
const GCP_ORG_DOCS = "prowler-cloud-gcp-organizations";
/** The GCP organization seeded by `mixedHierarchyFixture`. */
const GCP_ORG_NAME = "My GCP Organization";
/** The GCP folder seeded by `mixedHierarchyFixture`, and its node id. */
const GCP_FOLDER_NAME = "Engineering";
const GCP_FOLDER_NODE_ID = "node-gcp-eng";
/** Drive a fresh GCP org onboarding up to the authentication submit. */
async function authenticateGcpOrg(
harness: ProvidersPageHarness,
{ orgId = GCP_ORG_ID, name }: { orgId?: string; name?: string } = {},
): Promise<void> {
await harness.mount();
await harness.chooseGcpOrganizations();
await harness.fillGcpOrgDetails(orgId, name);
await harness.submitOrganizationDetails();
await harness.fillGcpServiceAccountKey(VALID_SA_KEY);
await harness.authenticate();
}
/** Drive a fresh GCP org onboarding up to the populated selection tree. */
async function onboardGcpToSelection(
harness: ProvidersPageHarness,
): Promise<void> {
await authenticateGcpOrg(harness, { name: "My GCP Org" });
await harness.waitForSelectionTree();
await harness.waitForProjectSelection();
}
interface ApplyRequestBody {
data: {
attributes: {
projects?: Array<{ project_id: string; alias?: string }>;
accounts?: unknown;
organizational_units?: unknown;
};
};
}
describe("GCP Organizations onboarding (Phase 2)", () => {
it("completes the happy path: setup → discovery → selection → apply → connect → launch step", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Terminology: the selection step counts "projects", never "accounts".
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
expect(harness.usesAccountWording()).toBe(false);
await harness.testConnections();
await harness.waitForProjectsConnected();
expect(harness.usesAccountWording()).toBe(false);
expect(harness.applyCallCount).toBe(1);
}, 40000);
it("resolves the created providers' uids with one filtered list, and no include", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
// The apply view rejects `include`, so the uids that map providers back to
// candidates are read from `/providers` — once for all of them, not once each.
expect(harness.applySentIncludeParam()).toBe(false);
expect(harness.providerUidLookupCount).toBe(1);
expect(harness.singleProviderFetchCount).toBe(0);
}, 40000);
it("links the wizard docs to the GCP organizations tutorial", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await harness.mount();
await harness.chooseGcpOrganizations();
expect(harness.hasDocsLinkTo(GCP_ORG_DOCS)).toBe(true);
}, 30000);
it("nests each project under its discovered folder and renders every folder once", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Folder identity is the resource `name`, which is what children carry as
// `parent`; reading it from any other field flattens the tree.
expect(harness.countContainerRows("Engineering")).toBe(1);
expect(harness.countContainerRows("Platform")).toBe(1);
expect(harness.containerRowUids().sort()).toEqual([
"folders/1000000001",
"folders/1000000002",
GCP_EMPTY_FOLDER,
GCP_BLOCKED_FOLDER,
]);
expect(
harness.isCandidateNestedUnder("prod-analytics", "Engineering"),
).toBe(true);
expect(harness.isCandidateNestedUnder("prod-platform", "Platform")).toBe(
true,
);
}, 40000);
it("prefills a project alias with its display name, never its resource name", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(harness.candidateAliasValue(/prod-analytics/)).toBe(
"Prod Analytics",
);
expect(harness.candidateAliasValue(/prod-analytics/)).not.toMatch(
/^projects\//,
);
}, 40000);
it("marks a folder with nothing selectable as inert, in project wording", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Project-less folders do reach the tree, and clicking them selects nothing —
// which the row has to say, in GCP's own nouns.
expect(harness.isContainerInert(GCP_EMPTY_FOLDER_NAME)).toBe(true);
expect(harness.inertContainerNote(GCP_EMPTY_FOLDER_NAME)).toBe(
"No projects available to select in this folder.",
);
expect(harness.isContainerInert(GCP_BLOCKED_FOLDER_NAME)).toBe(true);
// A folder holding a ready project stays selectable.
expect(harness.isContainerInert("Engineering")).toBe(false);
expect(harness.inertContainerNote("Engineering")).toBeNull();
expect(harness.usesAccountWording()).toBe(false);
}, 40000);
it("still opens an inert folder so its blocked projects are visible", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(
harness.isCandidateNestedUnder(
GCP_BLOCKED_FOLDER_PROJECT,
GCP_BLOCKED_FOLDER_NAME,
),
).toBe(true);
// The row collapses and re-expands rather than selecting: an inert folder that
// could not be opened would never explain itself.
await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME);
await harness.waitForTransition();
expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(false);
await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME);
await harness.waitForTransition();
expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(true);
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
}, 40000);
it("keeps a long project id inside its column instead of over the alias input", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildGcpDiscoveryResult({ includeLongIdProject: true }),
error: null,
},
}),
);
await onboardGcpToSelection(harness);
// Real layout, not class names: a leaf row that cannot shrink pushes the id
// over its neighbour instead of ellipsizing.
expect(harness.candidateRowOverflows(GCP_LONG_PROJECT_ID)).toBe(false);
}, 40000);
// These three cases pin how `/schedules/bulk`'s per-provider lists are read: a
// client looking one level too deep sees no lists and cannot tell them apart.
it("launches the organization after a partial schedule save", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: {
updated: [GCP_CREATED_PROVIDER_IDS[0]],
failed: [{ id: GCP_CREATED_PROVIDER_IDS[1], error: "Denied" }],
shape: "flat",
},
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
// The reason the API gave must reach the user — a count alone is unactionable.
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
expect(harness.organizationBulkScanCallCount).toBe(1);
}, 40000);
it("keeps the user on the launch step when no schedule could be saved", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: {
updated: [],
failed: GCP_CREATED_PROVIDER_IDS.map((id) => ({
id,
error: "Denied",
})),
shape: "flat",
},
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
expect(harness.isStillOnLaunchStep()).toBe(true);
expect(harness.organizationBulkScanCallCount).toBe(0);
}, 40000);
it("proceeds when the schedule response carries no result lists", async () => {
// The POST commits each schedule before answering, so an unreadable body must
// not strand the user on a schedule that already exists.
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: { updated: null, failed: [], shape: "bare" },
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
expect(harness.organizationBulkScanCallCount).toBe(1);
}, 40000);
it("sends a projects-only apply payload (no accounts, no organizational units)", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
const body = await harness.lastRequestBody<ApplyRequestBody>(
"POST",
"/apply",
);
const attributes = body?.data.attributes;
expect(attributes?.projects?.map((p) => p.project_id).sort()).toEqual([
"prod-analytics",
"prod-platform",
]);
// GCP derives folder ancestors server-side, so no accounts/OUs are ever sent.
expect(attributes?.accounts).toBeUndefined();
expect(attributes?.organizational_units).toBeUndefined();
// No alias was typed, so none is included.
expect(attributes?.projects?.every((p) => p.alias === undefined)).toBe(
true,
);
}, 40000);
it("includes an alias only for projects the user renamed", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.setCandidateAlias(/prod-analytics/, "Analytics Prod");
await harness.testConnections();
await harness.waitForProjectsConnected();
const body = await harness.lastRequestBody<ApplyRequestBody>(
"POST",
"/apply",
);
const projects = body?.data.attributes.projects ?? [];
const analytics = projects.find((p) => p.project_id === "prod-analytics");
const platform = projects.find((p) => p.project_id === "prod-platform");
expect(analytics?.alias).toBe("Analytics Prod");
expect(platform?.alias).toBeUndefined();
}, 40000);
it("disables blocked projects and excludes them from the selectable count", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(await harness.isCandidateBlocked(/legacy-sandbox/)).toBe(true);
// Two ready projects, the third (blocked) excluded from the count.
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
expect(harness.hasSelectedProjectCount(3, 3)).toBe(false);
}, 40000);
it("renders a folder as indeterminate when only some descendant projects are selected", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Both ready projects start selected → the Engineering folder is fully checked.
expect(harness.candidateCheckboxState(/Engineering/)).toBe("true");
// Deselect one descendant project; its ancestor folder goes indeterminate.
await harness.toggleCandidate(/prod-platform/);
await harness.waitForSelectedProjectCount(1, 2);
expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed");
}, 40000);
it("warns before replacing an existing organization credential, then proceeds on confirm", async () => {
const fixture: OrgFixture = gcpOnboardingFixture({
organizations: [
{
id: "org-gcp-existing",
orgType: ORGANIZATION_TYPE.GCP,
name: "Existing GCP Org",
externalId: GCP_ORG_ID,
rootExternalId: null,
providerIds: ["gp-existing-1", "gp-existing-2"],
nodeIds: [],
secretId: "secret-gcp-existing",
},
],
});
const harness = new ProvidersPageHarness(fixture);
await authenticateGcpOrg(harness);
// The credential is replaced only after the user confirms, and the warning
// states how many onboarded providers that re-authenticates.
await harness.waitForCredentialReplaceWarning();
expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true);
await harness.confirmCredentialReplace();
// Confirming updates the secret (PATCH) and continues into selection.
await harness.waitForSelectionTree();
await harness.waitForProjectSelection();
await harness.waitForSecretReplace();
}, 40000);
it("warns before an apply that overwrites already-onboarded project credentials", async () => {
const fixture = gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildGcpDiscoveryResult({
replaceProjectIds: ["prod-analytics"],
}),
error: null,
},
});
const harness = new ProvidersPageHarness(fixture);
await onboardGcpToSelection(harness);
await harness.testConnections();
// Pre-apply warning names the project whose credentials will be overwritten.
await harness.waitForCredentialReplaceWarning();
expect(harness.hasApplyOverwriteWarning(1, ["Prod Analytics"])).toBe(true);
expect(harness.applyCallCount).toBe(0);
await harness.confirmApplyOverwrite();
await harness.waitForProjectsConnected();
expect(harness.applyCallCount).toBe(1);
}, 40000);
it("surfaces a failed discovery and retries with a fresh discovery", async () => {
const fixture = gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.FAILED,
result: {},
error: "Service account lacks organization permissions",
},
});
const harness = new ProvidersPageHarness(fixture);
await authenticateGcpOrg(harness);
await harness.waitForDiscoveryFailure();
await harness.waitForDiscoveryCount(1);
// Retry triggers a brand-new discovery, not a resumed poll.
await harness.retryDiscovery();
await harness.waitForDiscoveryCount(2);
}, 40000);
});
describe("GCP Organizations connect step (Phase 2)", () => {
it("gates the GCP Organization method behind the cloud upgrade in OSS builds", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await harness.mount();
await harness.selectProviderType(/Google Cloud Platform/);
await harness.waitForMethodStep();
// Choosing the org method must NOT start the flow in OSS.
await harness.chooseMethod(/Add Multiple Projects With GCP Organization/);
await harness.waitForMethodStep();
expect(harness.hasOrganizationSetupStep()).toBe(false);
}, 30000);
});
describe("GCP Organizations providers page (Phase 2)", () => {
it("deletes a GCP folder with kind-aware copy and deletion-task polling", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForNodeGroup(GCP_FOLDER_NAME);
// GCP container nodes are "folders", not "organizational units".
await harness.openDeleteFolderFor(GCP_FOLDER_NAME);
await harness.waitForDeleteConfirmation();
expect(harness.hasDeleteWarningFor("folder")).toBe(true);
await harness.confirmDelete();
await harness.waitForNodeDelete(GCP_FOLDER_NODE_ID);
// The polled task completes when the per-provider deletions are dispatched, so
// the copy may report acceptance and never that the folder is gone.
await harness.waitForTaskPoll("del-task-");
await harness.waitForDeletionAccepted();
expect(harness.claimsDeletionFinished()).toBe(false);
}, 30000);
it("reports a failed deletion task instead of a false success", async () => {
const harness = new ProvidersPageHarness(
mixedHierarchyFixture({ deletionTaskState: "failed" }),
);
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(GCP_ORG_NAME);
await harness.openDeleteFor(GCP_ORG_NAME);
await harness.waitForDeleteConfirmation();
expect(harness.hasCascadeWarning(2)).toBe(true);
await harness.confirmDelete();
// A failed task is reported as not completed, and the hierarchy refetched so
// the restored subtree reappears.
await harness.waitForDeletionFailure();
}, 30000);
});
@@ -698,6 +698,15 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
return this.containsText(new RegExp(reason));
}
/** The href behind the success toast's "Go to scans" action. */
scansToastHref(): string | null {
return (
Array.from(this.container.querySelectorAll<HTMLAnchorElement>("a"))
.find((anchor) => /Go to scans/.test(anchor.textContent ?? ""))
?.getAttribute("href") ?? null
);
}
/** Whether the wizard is still showing the launch step (it did not navigate). */
isStillOnLaunchStep(): boolean {
return this.containsText(/Scan Schedule/);
@@ -1,134 +0,0 @@
import { describe, expect } from "vitest";
// The extended `it` carries the auto `seedRuntimeConfig` fixture — grouping is
// cloud-only, so the runtime-config island must exist before mounting.
import { it } from "@/__tests__/fixtures";
import { HIERARCHY_READ_FAILURE } from "@/__tests__/msw/handlers/organizations";
import {
awsHierarchyFixture,
displayOnlyOrgHierarchyFixture,
mixedHierarchyFixture,
} from "@/__tests__/msw/handlers/organizations.fixtures";
import { ProvidersPageHarness } from "./providers-page.harness";
// Phase 1 new-behavior coverage (the Phase 0 AWS baseline suite stays untouched):
// the providers page now consumes the canonical organization-nodes contract for
// BOTH organization types, deriving container labels from node `kind`, and
// surfaces an explicit notice when the hierarchy fetch degrades.
describe("Providers page — mixed AWS + GCP hierarchy display", () => {
it("groups both organizations, labelling nodes by kind (Organizational Unit vs Folder)", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
// Both organizations render as top-level groups.
await harness.waitForOrganizationRow("My AWS Organization");
await harness.waitForOrganizationRow("My GCP Organization");
// AWS organizational units and GCP folders both render as node groups.
await harness.waitForNodeGroup("Production");
await harness.waitForNodeGroup("Sandbox");
await harness.waitForNodeGroup("Engineering");
await harness.waitForNodeGroup("Platform");
// Container labels are kind-driven, never ID-prefix-driven: AWS nodes read
// "Organizational Unit", GCP nodes read "Folder".
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true);
expect(harness.hasNodeKindLabel("Folder")).toBe(true);
// Per-organization provider counts.
expect(harness.hasProviderCount(3)).toBe(true);
expect(harness.hasProviderCount(2)).toBe(true);
// Providers of both types render nested under their nodes, by alias.
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasProviderRow("Prod Analytics")).toBe(true);
expect(harness.hasProviderRow("Prod Platform")).toBe(true);
// Tripwire: those rows came from a real fetch of the canonical route.
expect(harness.hierarchyFetchCount).toBeGreaterThan(0);
}, 30000);
it("offers wizard re-entry to every organization type with a setup form", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow("My GCP Organization");
// GCP now owns a setup form, so "Update Credentials" re-enters the wizard on
// it. Renaming is a plain PATCH either way.
const actions = await harness.actionLabelsFor("My GCP Organization");
expect(actions).toContain("Edit Organization Name");
expect(actions).toContain("Update Credentials");
// The AWS organization in the same table keeps it.
const awsActions = await harness.actionLabelsFor("My AWS Organization");
expect(awsActions).toContain("Update Credentials");
}, 30000);
});
describe("Providers page — organization type without an onboarding flow", () => {
it("groups it with its own wording and offers no wizard re-entry", async () => {
const harness = new ProvidersPageHarness(displayOnlyOrgHierarchyFixture());
await harness.mount({ openWizard: false });
// Grouping is display-driven, so the organization still renders as a group.
await harness.waitForOrganizationRow("Contoso Tenant");
expect(harness.hasProviderRow("contoso-prod")).toBe(true);
// It never inherits AWS wording.
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
// Credential updates re-enter the organization wizard, which only exists for
// onboardable types; renaming is a plain PATCH and stays available.
const actions = await harness.actionLabelsFor("Contoso Tenant");
expect(actions).toContain("Edit Organization Name");
expect(actions).not.toContain("Update Credentials");
}, 30000);
});
describe("Providers page — degraded hierarchy view", () => {
it("shows a non-blocking notice and keeps providers listed flat when hierarchy is unavailable", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({
openWizard: false,
hierarchyFailure: HIERARCHY_READ_FAILURE.ALL,
});
await harness.waitForDegradedHierarchyNotice();
expect(harness.hasUngroupedProvidersNotice()).toBe(true);
// Providers are still present despite grouping being unavailable, and no
// organization group row survives the failed hierarchy fetch.
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false);
}, 30000);
it("degrades the same way when only the node read fails", async () => {
// AWS accounts hang off the OUs, so the organization's own `providers`
// relationship is empty and its row drops out along with the nodes.
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({
openWizard: false,
hierarchyFailure: HIERARCHY_READ_FAILURE.NODES,
});
await harness.waitForDegradedHierarchyNotice();
expect(harness.hasUngroupedProvidersNotice()).toBe(true);
await harness.waitForProviderRow("prod-web");
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false);
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
}, 30000);
it("shows no notice when the hierarchy is available", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow("My AWS Organization");
expect(harness.hasDegradedHierarchyNotice()).toBe(false);
}, 30000);
});
@@ -1,11 +1,30 @@
import { describe, expect } from "vitest";
// One integration file per real page: the whole providers page lives here.
// `it` comes from the fixtures module for its auto `seedRuntimeConfig` — grouping
// is cloud-only, so the runtime-config island must exist before mounting.
import { it } from "@/__tests__/fixtures";
import { HIERARCHY_READ_FAILURE } from "@/__tests__/msw/handlers/organizations";
import {
awsHierarchyFixture,
awsOnboardingFixture,
buildGcpDiscoveryResult,
displayOnlyOrgHierarchyFixture,
DISCOVERY_STATUS_VALUE,
GCP_BLOCKED_FOLDER,
GCP_BLOCKED_FOLDER_NAME,
GCP_BLOCKED_FOLDER_PROJECT,
GCP_CREATED_PROVIDER_IDS,
GCP_EMPTY_FOLDER,
GCP_EMPTY_FOLDER_NAME,
GCP_LONG_PROJECT_ID,
GCP_ORG_ID,
gcpOnboardingFixture,
mixedHierarchyFixture,
type OrgFixture,
} from "@/__tests__/msw/handlers/organizations.fixtures";
import { ORGANIZATION_TYPE } from "@/types/organizations";
import { SCAN_JOBS_TAB } from "@/types/scans";
import { ProvidersPageHarness } from "./providers-page.harness";
@@ -14,7 +33,6 @@ const AWS_ROLE_ARN = "arn:aws:iam::111111111111:role/ProwlerScan";
/** The organization id seeded by `awsHierarchyFixture`. */
const AWS_HIERARCHY_ORG_ID = "org-aws-1";
const AWS_ORG_NAME = "My AWS Organization";
/** A world where one of the two ready accounts fails its connection test. */
const partialConnectionFixture = (): OrgFixture =>
awsOnboardingFixture({
connectionByUid: {
@@ -37,187 +55,781 @@ async function onboardToSelection(
await harness.waitForAccountSelection();
}
describe("AWS Organizations onboarding (baseline)", () => {
it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => {
const harness = new ProvidersPageHarness(awsOnboardingFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForAccountsConnected();
expect(harness.applyCallCount).toBe(1);
// Drive the final action: save the schedules for both created providers and
// launch an initial scan for each.
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
expect(harness.scheduleBulkCallCount).toBe(1);
expect(harness.organizationBulkScanCallCount).toBe(1);
}, 60000);
it("disables blocked accounts and excludes them from the selectable count", async () => {
const harness = new ProvidersPageHarness(awsOnboardingFixture());
await onboardToSelection(harness);
expect(await harness.isAccountBlocked("333333333333")).toBe(true);
await harness.waitForSelectedCount(2, 2);
expect(harness.hasSelectedCount(3, 3)).toBe(false);
}, 40000);
it("retries only the failed connections without re-applying", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForConnectionError();
await harness.waitForConnectionAttempts(2);
expect(harness.applyCallCount).toBe(1);
await harness.testConnections();
await harness.waitForConnectionAttempts(3);
expect(harness.applyCallCount).toBe(1);
}, 60000);
it("settles each account the moment its own test finishes", async () => {
// Given — one account connects on the first read, the other stays running.
const harness = new ProvidersPageHarness(
awsOnboardingFixture({
connectionByUid: {
"111111111111": { connected: true },
"222222222222": { connected: true, executingPolls: 2 },
},
}),
);
await onboardToSelection(harness);
// When
await harness.testConnections();
await harness.waitForCandidateConnectionState(/111111111111/, "success");
// Then — the finished account reports while the slow one is still testing.
expect(harness.candidateConnectionState(/222222222222/)).toBe("testing");
await harness.waitForAccountsConnected();
}, 60000);
it("re-applies when the selection changes after an apply", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForConnectionError();
expect(harness.applyCallCount).toBe(1);
await harness.goBack();
await harness.toggleAccount("222222222222");
await harness.testConnections();
await harness.waitForApplyCount(2);
}, 60000);
it("allows skipping validation once at least one account connected", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForConnectionError();
await harness.skipValidation();
await harness.waitForReadyToScan();
}, 60000);
const VALID_SA_KEY = JSON.stringify({
type: "service_account",
project_id: "prowler-scan",
private_key_id: "abcdef",
client_email: "prowler@prowler-scan.iam.gserviceaccount.com",
});
describe("AWS Organizations providers page (baseline)", () => {
it("groups providers under their organization and OUs with kind-driven labels", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
const GCP_ORG_DOCS = "prowler-cloud-gcp-organizations";
// Organization group row + its OU sub-groups (expanded by default in cloud).
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.waitForNodeGroup("Production");
await harness.waitForNodeGroup("Sandbox");
/** The GCP organization seeded by `mixedHierarchyFixture`. */
const GCP_ORG_NAME = "My GCP Organization";
/** The GCP folder seeded by `mixedHierarchyFixture`, and its node id. */
const GCP_FOLDER_NAME = "Engineering";
const GCP_FOLDER_NODE_ID = "node-gcp-eng";
// Node group rows are labelled by kind, not by ID prefix.
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true);
// Organization row surfaces its total provider count.
expect(harness.hasProviderCount(3)).toBe(true);
/** Drive a fresh GCP org onboarding up to the authentication submit. */
async function authenticateGcpOrg(
harness: ProvidersPageHarness,
{ orgId = GCP_ORG_ID, name }: { orgId?: string; name?: string } = {},
): Promise<void> {
await harness.mount();
await harness.chooseGcpOrganizations();
await harness.fillGcpOrgDetails(orgId, name);
await harness.submitOrganizationDetails();
await harness.fillGcpServiceAccountKey(VALID_SA_KEY);
await harness.authenticate();
}
// Providers render nested under their OU, addressed by alias.
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasProviderRow("prod-api")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
/** Drive a fresh GCP org onboarding up to the populated selection tree. */
async function onboardGcpToSelection(
harness: ProvidersPageHarness,
): Promise<void> {
await authenticateGcpOrg(harness, { name: "My GCP Org" });
await harness.waitForSelectionTree();
await harness.waitForProjectSelection();
}
// Tripwire: the rows above came from real requests, so a loader that stops
// fetching the hierarchy (either route) fails here instead of staying green
// on harness-supplied data.
expect(harness.organizationFetchCount).toBeGreaterThan(0);
expect(harness.hierarchyFetchCount).toBeGreaterThan(0);
}, 30000);
interface ApplyProjectRequest {
project_id: string;
alias?: string;
}
it("edits the organization name via the inline modal (PATCH)", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
interface ApplyRequestAttributes {
projects?: ApplyProjectRequest[];
accounts?: unknown;
organizational_units?: unknown;
}
await harness.openEditNameFor(AWS_ORG_NAME);
interface ApplyRequestData {
attributes: ApplyRequestAttributes;
}
// The edit-name affordance is an inline modal (not the wizard) today.
await harness.waitForEditNameModal();
await harness.fillEditName("Renamed AWS Org");
await harness.saveName();
interface ApplyRequestBody {
data: ApplyRequestData;
}
await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID);
}, 30000);
describe("Organization onboarding wizard", () => {
describe("AWS Organizations", () => {
describe("Full onboarding run", () => {
it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => {
const harness = new ProvidersPageHarness(awsOnboardingFixture());
await onboardToSelection(harness);
it("re-enters the wizard at the authentication step to update credentials", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.testConnections();
await harness.waitForAccountsConnected();
await harness.openUpdateCredentialsFor(AWS_ORG_NAME);
expect(harness.applyCallCount).toBe(1);
// Opens the org wizard directly on the AWS authentication (access) phase.
await harness.waitForAuthenticationStep();
expect(harness.hasAuthenticateButton()).toBe(true);
// Edit-credentials re-entry skips the details step, so Back is hidden.
expect(harness.hasBackButton()).toBe(false);
}, 30000);
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
it("deletes an organization with a cascade warning and deletion-task polling", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
expect(harness.scheduleBulkCallCount).toBe(1);
expect(harness.organizationBulkScanCallCount).toBe(1);
}, 60000);
});
await harness.openDeleteFor(AWS_ORG_NAME);
describe("Account selection", () => {
it("disables blocked accounts and excludes them from the selectable count", async () => {
const harness = new ProvidersPageHarness(awsOnboardingFixture());
await onboardToSelection(harness);
// Cascade confirmation dialog, stating the affected provider count.
await harness.waitForDeleteConfirmation();
expect(harness.hasDeleteWarning()).toBe(true);
expect(harness.hasCascadeWarning(3)).toBe(true);
expect(await harness.isAccountBlocked("333333333333")).toBe(true);
await harness.confirmDelete();
await harness.waitForSelectedCount(2, 2);
expect(harness.hasSelectedCount(3, 3)).toBe(false);
}, 40000);
});
await harness.waitForOrganizationDelete(AWS_HIERARCHY_ORG_ID);
// Deletion is a 202 + task: nothing is reported until the UI has polled it.
await harness.waitForTaskPoll();
}, 30000);
describe("Connection testing", () => {
it("retries only the failed connections without re-applying", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
it("renders a flat provider list (no org/OU grouping) on-prem", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.testConnections();
await harness.waitForConnectionError();
await harness.waitForConnectionAttempts(2);
expect(harness.applyCallCount).toBe(1);
// Providers still render, but ungrouped: no organization row, no OU labels.
await harness.waitForProviderRow("prod-web");
expect(harness.hasProviderRow("prod-api")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasOrganizationRow(AWS_ORG_NAME)).toBe(false);
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
// On-prem never asks for the organization hierarchy at all.
expect(harness.hierarchyFetchCount).toBe(0);
}, 30000);
await harness.testConnections();
await harness.waitForConnectionAttempts(3);
expect(harness.applyCallCount).toBe(1);
}, 60000);
it("settles each account the moment its own test finishes", async () => {
const harness = new ProvidersPageHarness(
awsOnboardingFixture({
connectionByUid: {
"111111111111": { connected: true },
"222222222222": { connected: true, executingPolls: 2 },
},
}),
);
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForCandidateConnectionState(
/111111111111/,
"success",
);
expect(harness.candidateConnectionState(/222222222222/)).toBe(
"testing",
);
await harness.waitForAccountsConnected();
}, 60000);
it("re-applies when the selection changes after an apply", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForConnectionError();
expect(harness.applyCallCount).toBe(1);
await harness.goBack();
await harness.toggleAccount("222222222222");
await harness.testConnections();
await harness.waitForApplyCount(2);
}, 60000);
it("allows skipping validation once at least one account connected", async () => {
const harness = new ProvidersPageHarness(partialConnectionFixture());
await onboardToSelection(harness);
await harness.testConnections();
await harness.waitForConnectionError();
await harness.skipValidation();
await harness.waitForReadyToScan();
}, 60000);
});
});
describe("GCP Organizations", () => {
describe("Full onboarding run", () => {
it("completes the happy path: setup → discovery → selection → apply → connect → launch step", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Terminology: the selection step counts "projects", never "accounts".
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
expect(harness.usesAccountWording()).toBe(false);
await harness.testConnections();
await harness.waitForProjectsConnected();
expect(harness.usesAccountWording()).toBe(false);
expect(harness.applyCallCount).toBe(1);
}, 40000);
});
describe("Wizard entry", () => {
it("links the wizard docs to the GCP organizations tutorial", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await harness.mount();
await harness.chooseGcpOrganizations();
expect(harness.hasDocsLinkTo(GCP_ORG_DOCS)).toBe(true);
}, 30000);
it("gates the GCP Organization method behind the cloud upgrade in OSS builds", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await harness.mount();
await harness.selectProviderType(/Google Cloud Platform/);
await harness.waitForMethodStep();
await harness.chooseMethod(
/Add Multiple Projects With GCP Organization/,
);
await harness.waitForMethodStep();
expect(harness.hasOrganizationSetupStep()).toBe(false);
}, 30000);
});
describe("Project selection", () => {
it("nests each project under its discovered folder and renders every folder once", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
// Folder identity is the resource `name`, which is what children carry as
// `parent`; reading it from any other field flattens the tree.
expect(harness.countContainerRows("Engineering")).toBe(1);
expect(harness.countContainerRows("Platform")).toBe(1);
expect(harness.containerRowUids().sort()).toEqual([
"folders/1000000001",
"folders/1000000002",
GCP_EMPTY_FOLDER,
GCP_BLOCKED_FOLDER,
]);
expect(
harness.isCandidateNestedUnder("prod-analytics", "Engineering"),
).toBe(true);
expect(
harness.isCandidateNestedUnder("prod-platform", "Platform"),
).toBe(true);
}, 40000);
it("prefills a project alias with its display name, never its resource name", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(harness.candidateAliasValue(/prod-analytics/)).toBe(
"Prod Analytics",
);
expect(harness.candidateAliasValue(/prod-analytics/)).not.toMatch(
/^projects\//,
);
}, 40000);
it("marks a folder with nothing selectable as inert, in project wording", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(harness.isContainerInert(GCP_EMPTY_FOLDER_NAME)).toBe(true);
expect(harness.inertContainerNote(GCP_EMPTY_FOLDER_NAME)).toBe(
"No projects available to select in this folder.",
);
expect(harness.isContainerInert(GCP_BLOCKED_FOLDER_NAME)).toBe(true);
expect(harness.isContainerInert("Engineering")).toBe(false);
expect(harness.inertContainerNote("Engineering")).toBeNull();
expect(harness.usesAccountWording()).toBe(false);
}, 40000);
it("still opens an inert folder so its blocked projects are visible", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(
harness.isCandidateNestedUnder(
GCP_BLOCKED_FOLDER_PROJECT,
GCP_BLOCKED_FOLDER_NAME,
),
).toBe(true);
// The row collapses and re-expands rather than selecting.
await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME);
await harness.waitForTransition();
expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(
false,
);
await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME);
await harness.waitForTransition();
expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(
true,
);
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
}, 40000);
it("keeps a long project id inside its column instead of over the alias input", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildGcpDiscoveryResult({ includeLongIdProject: true }),
error: null,
},
}),
);
await onboardGcpToSelection(harness);
// Real layout boxes, not class names: an unshrinkable row pushes the id out.
expect(harness.candidateRowOverflows(GCP_LONG_PROJECT_ID)).toBe(false);
}, 40000);
it("disables blocked projects and excludes them from the selectable count", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(await harness.isCandidateBlocked(/legacy-sandbox/)).toBe(true);
expect(harness.hasSelectedProjectCount(2, 2)).toBe(true);
expect(harness.hasSelectedProjectCount(3, 3)).toBe(false);
}, 40000);
it("renders a folder as indeterminate when only some descendant projects are selected", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
expect(harness.candidateCheckboxState(/Engineering/)).toBe("true");
await harness.toggleCandidate(/prod-platform/);
await harness.waitForSelectedProjectCount(1, 2);
expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed");
}, 40000);
});
describe("Apply payload", () => {
it("sends a projects-only apply payload (no accounts, no organizational units)", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
const body = await harness.lastRequestBody<ApplyRequestBody>(
"POST",
"/apply",
);
const attributes = body?.data.attributes;
expect(attributes?.projects?.map((p) => p.project_id).sort()).toEqual([
"prod-analytics",
"prod-platform",
]);
// GCP derives folder ancestors server-side, so no accounts/OUs are ever sent.
expect(attributes?.accounts).toBeUndefined();
expect(attributes?.organizational_units).toBeUndefined();
expect(attributes?.projects?.every((p) => p.alias === undefined)).toBe(
true,
);
}, 40000);
it("includes an alias only for projects the user renamed", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.setCandidateAlias(/prod-analytics/, "Analytics Prod");
await harness.testConnections();
await harness.waitForProjectsConnected();
const body = await harness.lastRequestBody<ApplyRequestBody>(
"POST",
"/apply",
);
const projects = body?.data.attributes.projects ?? [];
// A payload that dropped `prod-platform` would pass the optional lookups.
expect(projects.map((p) => p.project_id).sort()).toEqual([
"prod-analytics",
"prod-platform",
]);
const analytics = projects.find(
(p) => p.project_id === "prod-analytics",
);
const platform = projects.find((p) => p.project_id === "prod-platform");
expect(analytics?.alias).toBe("Analytics Prod");
expect(platform?.alias).toBeUndefined();
}, 40000);
it("resolves the created providers' uids with one filtered list, and no include", async () => {
const harness = new ProvidersPageHarness(gcpOnboardingFixture());
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
// The apply view rejects `include`, so the uids that map providers back to
// candidates are read from `/providers` — once for all of them, not once each.
expect(harness.applySentIncludeParam()).toBe(false);
expect(harness.providerUidLookupCount).toBe(1);
expect(harness.singleProviderFetchCount).toBe(0);
}, 40000);
});
describe("Credential conflicts", () => {
it("warns before replacing an existing organization credential, then proceeds on confirm", async () => {
const fixture: OrgFixture = gcpOnboardingFixture({
organizations: [
{
id: "org-gcp-existing",
orgType: ORGANIZATION_TYPE.GCP,
name: "Existing GCP Org",
externalId: GCP_ORG_ID,
rootExternalId: null,
providerIds: ["gp-existing-1", "gp-existing-2"],
nodeIds: [],
secretId: "secret-gcp-existing",
},
],
});
const harness = new ProvidersPageHarness(fixture);
await authenticateGcpOrg(harness);
await harness.waitForCredentialReplaceWarning();
expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true);
await harness.confirmCredentialReplace();
await harness.waitForSelectionTree();
await harness.waitForProjectSelection();
await harness.waitForSecretReplace();
}, 40000);
it("warns before an apply that overwrites already-onboarded project credentials", async () => {
const fixture = gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildGcpDiscoveryResult({
replaceProjectIds: ["prod-analytics"],
}),
error: null,
},
});
const harness = new ProvidersPageHarness(fixture);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForCredentialReplaceWarning();
expect(harness.hasApplyOverwriteWarning(1, ["Prod Analytics"])).toBe(
true,
);
expect(harness.applyCallCount).toBe(0);
await harness.confirmApplyOverwrite();
await harness.waitForProjectsConnected();
expect(harness.applyCallCount).toBe(1);
}, 40000);
});
describe("Discovery failures", () => {
it("surfaces a failed discovery and retries with a fresh discovery", async () => {
const fixture = gcpOnboardingFixture({
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.FAILED,
result: {},
error: "Service account lacks organization permissions",
},
});
const harness = new ProvidersPageHarness(fixture);
await authenticateGcpOrg(harness);
await harness.waitForDiscoveryFailure();
await harness.waitForDiscoveryCount(1);
await harness.retryDiscovery();
await harness.waitForDiscoveryCount(2);
}, 40000);
});
describe("Launch and scheduling", () => {
it("launches the organization after a partial schedule save", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: {
updated: [GCP_CREATED_PROVIDER_IDS[0]],
failed: [{ id: GCP_CREATED_PROVIDER_IDS[1], error: "Denied" }],
shape: "flat",
},
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
// The reason the API gave must reach the user — a count alone is unactionable.
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
expect(harness.organizationBulkScanCallCount).toBe(1);
// Derived independently of the legacy path's tab, so nothing else covers it.
expect(harness.scansToastHref()).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
}, 40000);
it("keeps the user on the launch step when no schedule could be saved", async () => {
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: {
updated: [],
failed: GCP_CREATED_PROVIDER_IDS.map((id) => ({
id,
error: "Denied",
})),
shape: "flat",
},
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
expect(harness.isStillOnLaunchStep()).toBe(true);
expect(harness.organizationBulkScanCallCount).toBe(0);
}, 40000);
it("proceeds when the schedule response carries no result lists", async () => {
// The POST commits each schedule before answering, so an unreadable body must
// not strand the user on a schedule that already exists.
const harness = new ProvidersPageHarness(
gcpOnboardingFixture({
scheduleBulk: { updated: null, failed: [], shape: "bare" },
}),
);
await onboardGcpToSelection(harness);
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
expect(harness.organizationBulkScanCallCount).toBe(1);
}, 40000);
});
});
});
describe("Providers page", () => {
describe("Organization hierarchy display", () => {
describe("AWS Organizations", () => {
it("groups providers under their organization and OUs with kind-driven labels", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.waitForNodeGroup("Production");
await harness.waitForNodeGroup("Sandbox");
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true);
expect(harness.hasProviderCount(3)).toBe(true);
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasProviderRow("prod-api")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
// Tripwire: a loader that stops fetching the hierarchy (either route) fails
// here instead of staying green on harness-supplied data.
expect(harness.organizationFetchCount).toBeGreaterThan(0);
expect(harness.hierarchyFetchCount).toBeGreaterThan(0);
}, 30000);
});
describe("Mixed AWS + GCP", () => {
it("groups both organizations, labelling nodes by kind (Organizational Unit vs Folder)", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow("My AWS Organization");
await harness.waitForOrganizationRow("My GCP Organization");
await harness.waitForNodeGroup("Production");
await harness.waitForNodeGroup("Sandbox");
await harness.waitForNodeGroup("Engineering");
await harness.waitForNodeGroup("Platform");
// Labels are kind-driven, never ID-prefix-driven.
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true);
expect(harness.hasNodeKindLabel("Folder")).toBe(true);
expect(harness.hasProviderCount(3)).toBe(true);
expect(harness.hasProviderCount(2)).toBe(true);
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasProviderRow("Prod Analytics")).toBe(true);
expect(harness.hasProviderRow("Prod Platform")).toBe(true);
// Tripwire: those rows came from a real fetch of the canonical route.
expect(harness.hierarchyFetchCount).toBeGreaterThan(0);
}, 30000);
});
describe("Organization type without an onboarding flow", () => {
it("groups it with its own wording and offers no wizard re-entry", async () => {
const harness = new ProvidersPageHarness(
displayOnlyOrgHierarchyFixture(),
);
await harness.mount({ openWizard: false });
// Grouping is display-driven, so the organization still renders as a group.
await harness.waitForOrganizationRow("Contoso Tenant");
expect(harness.hasProviderRow("contoso-prod")).toBe(true);
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
// The wizard only exists for onboardable types; renaming is a plain PATCH.
const actions = await harness.actionLabelsFor("Contoso Tenant");
expect(actions).toContain("Edit Organization Name");
expect(actions).not.toContain("Update Credentials");
}, 30000);
});
describe("On-prem builds", () => {
it("renders a flat provider list (no org/OU grouping) on-prem", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForProviderRow("prod-web");
expect(harness.hasProviderRow("prod-api")).toBe(true);
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasOrganizationRow(AWS_ORG_NAME)).toBe(false);
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
// On-prem never asks for the organization hierarchy at all.
expect(harness.hierarchyFetchCount).toBe(0);
}, 30000);
});
describe("Degraded hierarchy reads", () => {
it("shows a non-blocking notice and keeps providers listed flat when hierarchy is unavailable", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({
openWizard: false,
hierarchyFailure: HIERARCHY_READ_FAILURE.ALL,
});
await harness.waitForDegradedHierarchyNotice();
expect(harness.hasUngroupedProvidersNotice()).toBe(true);
expect(harness.hasProviderRow("prod-web")).toBe(true);
expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false);
}, 30000);
it("degrades the same way when only the node read fails", async () => {
// AWS accounts hang off the OUs, so the organization's own `providers`
// relationship is empty and its row drops out along with the nodes.
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({
openWizard: false,
hierarchyFailure: HIERARCHY_READ_FAILURE.NODES,
});
await harness.waitForDegradedHierarchyNotice();
expect(harness.hasUngroupedProvidersNotice()).toBe(true);
await harness.waitForProviderRow("prod-web");
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false);
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
}, 30000);
it("shows no notice when the hierarchy is available", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow("My AWS Organization");
expect(harness.hasDegradedHierarchyNotice()).toBe(false);
}, 30000);
});
});
describe("Organization management actions", () => {
describe("AWS Organizations", () => {
it("edits the organization name via the inline modal (PATCH)", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.openEditNameFor(AWS_ORG_NAME);
await harness.waitForEditNameModal();
await harness.fillEditName("Renamed AWS Org");
await harness.saveName();
await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID);
}, 30000);
it("re-enters the wizard at the authentication step to update credentials", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.openUpdateCredentialsFor(AWS_ORG_NAME);
await harness.waitForAuthenticationStep();
expect(harness.hasAuthenticateButton()).toBe(true);
// Edit-credentials re-entry skips the details step, so Back is hidden.
expect(harness.hasBackButton()).toBe(false);
}, 30000);
it("deletes an organization with a cascade warning and deletion-task polling", async () => {
const harness = new ProvidersPageHarness(awsHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(AWS_ORG_NAME);
await harness.openDeleteFor(AWS_ORG_NAME);
await harness.waitForDeleteConfirmation();
expect(harness.hasDeleteWarning()).toBe(true);
expect(harness.hasCascadeWarning(3)).toBe(true);
await harness.confirmDelete();
await harness.waitForOrganizationDelete(AWS_HIERARCHY_ORG_ID);
// Deletion is a 202 + task: nothing is reported until the UI has polled it.
await harness.waitForTaskPoll();
}, 30000);
});
describe("GCP Organizations", () => {
it("deletes a GCP folder with kind-aware copy and deletion-task polling", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForNodeGroup(GCP_FOLDER_NAME);
// GCP container nodes are "folders", not "organizational units".
await harness.openDeleteFolderFor(GCP_FOLDER_NAME);
await harness.waitForDeleteConfirmation();
expect(harness.hasDeleteWarningFor("folder")).toBe(true);
await harness.confirmDelete();
await harness.waitForNodeDelete(GCP_FOLDER_NODE_ID);
// The polled task completes when the per-provider deletions are dispatched, so
// the copy may report acceptance and never that the folder is gone.
await harness.waitForTaskPoll("del-task-");
await harness.waitForDeletionAccepted();
expect(harness.claimsDeletionFinished()).toBe(false);
}, 30000);
it("reports a failed deletion task instead of a false success", async () => {
const harness = new ProvidersPageHarness(
mixedHierarchyFixture({ deletionTaskState: "failed" }),
);
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow(GCP_ORG_NAME);
await harness.openDeleteFor(GCP_ORG_NAME);
await harness.waitForDeleteConfirmation();
expect(harness.hasCascadeWarning(2)).toBe(true);
await harness.confirmDelete();
await harness.waitForDeletionFailure();
}, 30000);
});
describe("Across organization types", () => {
it("offers wizard re-entry to every organization type with a setup form", async () => {
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
await harness.mount({ openWizard: false });
await harness.waitForOrganizationRow("My GCP Organization");
// Every type with a setup form offers "Update Credentials".
const actions = await harness.actionLabelsFor("My GCP Organization");
expect(actions).toContain("Edit Organization Name");
expect(actions).toContain("Update Credentials");
const awsActions = await harness.actionLabelsFor("My AWS Organization");
expect(awsActions).toContain("Update Credentials");
}, 30000);
});
});
});