Files
prowler/ui/app/(prowler)/providers/providers-page.harness.tsx
T

1235 lines
42 KiB
TypeScript

/**
* Page-level test harness for the providers page + organizations onboarding
* wizard (Vitest Browser Mode).
*
* Mirrors the attack-paths harness pattern: it owns mounting and MSW wiring and
* exposes semantic methods so tests interact through intent ("choose AWS
* Organizations", "authenticate", "test connections") rather than raw
* selectors. Request assertions are domain-named too (`applyCallCount`,
* `waitForOrganizationRename`, `taskPollCount`, …); the raw HTTP-verb+path
* `waitForRequest` primitive stays internal. Discovery/connection polling is
* real (MSW returns terminal states on the first poll), so flow methods wait
* on the resulting UI.
*/
import { BrowserHarness } from "@/__tests__/browser-harness";
import {
handlersForOrganizations,
HIERARCHY_READ_FAILURE,
type HierarchyReadFailure,
} from "@/__tests__/msw/handlers/organizations";
import type { OrgFixture } from "@/__tests__/msw/handlers/organizations.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
} from "@/lib/providers-navigation";
import type { SearchParamsProps } from "@/types";
import { ProvidersTabContent } from "./providers-tab-content";
const INITIAL_SCAN_LABEL = "Launch an initial scan now for immediate findings";
interface MountOptions {
/** Seed `?addProvider=true` so the wizard opens on mount. Default true. */
openWizard?: boolean;
/** Which hierarchy read fails, so the loader derives the status. Default none. */
hierarchyFailure?: HierarchyReadFailure;
}
/**
* Attributes a `POST /organizations` carries. `root_external_id` is deliberately
* absent: a test asserting the client sends none reads the parsed body, not this
* shape.
*/
export interface OrganizationCreateAttributes {
name?: string;
org_type?: string;
external_id?: string;
}
export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
get applyCallCount(): number {
return this.countRequests("POST", "/apply");
}
/** `POST /providers` alone; the substring match would also count secrets. */
get providerCreateCallCount(): number {
return (
this.countRequests("POST", "/providers") - this.secretCreateCallCount
);
}
get secretCreateCallCount(): number {
return this.countRequests("POST", "/providers/secrets");
}
/**
* Whether any apply asked the endpoint to include related resources, which it
* rejects outright — a tripwire, not a preference.
*/
applySentIncludeParam(): boolean {
return this.requestLog.some(
(entry) =>
entry.method === "POST" &&
entry.url.includes("/apply") &&
entry.url.includes("include="),
);
}
/** Single-provider reads — one per created provider is the N+1 this avoids. */
get singleProviderFetchCount(): number {
return this.requestLog.filter(
(entry) =>
entry.method === "GET" && /\/providers\/[^/?]+$/.test(entry.url),
).length;
}
/** Filtered provider-list reads, which resolve every created uid at once. */
get providerUidLookupCount(): number {
return this.requestLog.filter(
(entry) =>
entry.method === "GET" && entry.url.includes("filter%5Bid__in%5D"),
).length;
}
private get connectionCallCount(): number {
return this.countRequests("POST", "/connection");
}
/** How many times the page fetched the organization list over HTTP. */
get organizationFetchCount(): number {
return this.countRequests("GET", "/organizations");
}
/**
* Attributes the organization was created with. Matched on the collection path
* itself rather than a substring: the nested `discover` and `apply` POSTs also
* live under `/organizations` and carry no body to read.
*/
async createdOrganizationAttributes(): Promise<OrganizationCreateAttributes | null> {
const entry = [...this.requestLog]
.reverse()
.find(
(r) =>
r.method === "POST" &&
new URL(r.url).pathname.endsWith("/organizations"),
);
if (!entry) return null;
const body = (await entry.request.clone().json()) as {
data?: { attributes?: OrganizationCreateAttributes };
};
return body?.data?.attributes ?? null;
}
/**
* How many times the page fetched the organization hierarchy over HTTP —
* either route, so this stays a tripwire for "the hierarchy is still
* requested" across the deprecated → canonical migration.
*/
get hierarchyFetchCount(): number {
return (
this.countRequests("GET", "/organizational-units") +
this.countRequests("GET", "/organization-nodes")
);
}
/** How many bulk schedule saves the launch step issued. */
get scheduleBulkCallCount(): number {
return this.countRequests("POST", "/schedules/bulk");
}
/** How many organization bulk scan operations were launched. */
get organizationBulkScanCallCount(): number {
return this.countRequests("POST", "/scans/bulk");
}
// --- Mount + environment ------------------------------------------------
private seedWizardUrl(openWizard: boolean): void {
const params = new URLSearchParams();
if (openWizard) {
params.set(ADD_PROVIDER_SEARCH_PARAM, ADD_PROVIDER_SEARCH_VALUE);
}
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `/providers?${query}` : "/providers",
);
}
private searchParams(): SearchParamsProps {
return Object.fromEntries(new URLSearchParams(window.location.search));
}
/**
* Mount production's own providers-tab content, the component
* `providers/page.tsx` renders inside its Suspense boundary. It owns the whole
* data path — deriving `isCloud()`, loading the view data (providers, groups,
* schedules, organization hierarchy) and the scan configurations, then wiring
* them into the view — so nothing here duplicates it and drift can't hide in
* the harness. Requests go through MSW; the shared `render` supplies the app
* shell (session, theme, Toaster) the production layout provides.
*
* It's an async server component: React's client renderer can't render async
* components, so it is called and its returned element is what gets rendered.
* (`page.tsx`'s default export can't be used at all — it returns Suspense
* wrapping async children, which only a server renderer resolves.)
*/
async mount({
openWizard = true,
hierarchyFailure = HIERARCHY_READ_FAILURE.NONE,
}: MountOptions = {}): Promise<void> {
this.seedWizardUrl(openWizard);
worker.use(...handlersForOrganizations(this.fixture, { hierarchyFailure }));
this.trackRequests(worker);
render(await ProvidersTabContent({ searchParams: this.searchParams() }));
}
// --- Wizard: connect step ----------------------------------------------
/** Select a provider in the wizard's provider picker (auto-advances to step 2). */
async selectProviderType(
name: RegExp = /Amazon Web Services/,
): Promise<void> {
const option = await this.waitFor(() => this.byRoleName("option", name));
await this.user.click(option);
}
/** Click a method card in a provider's method selector by its title. */
async chooseMethod(name: RegExp): Promise<void> {
const card = await this.waitFor(() => this.byRoleName("radio", name));
await this.user.click(card);
}
/** Enter the AWS Organizations onboarding flow from a fresh wizard. */
async chooseAwsOrganizations(): Promise<void> {
await this.selectProviderType(/Amazon Web Services/);
// AWS hosts its single-account/organization switch as tabs on its connect step.
const tab = await this.waitFor(() =>
this.byRoleName("tab", /Full AWS Organization/),
);
await this.user.click(tab);
await this.waitForText(/Organization Details/);
}
/** Labels of the wizard's progress stepper, top to bottom. */
stepperLabels(): string[] {
return Array.from(
document.querySelectorAll('nav[aria-label="Wizard progress"] span'),
(node) => node.textContent?.trim() ?? "",
);
}
/** Wait until the AWS single-account connect step (with its method tabs) is showing. */
async waitForAwsConnectStep(): Promise<void> {
await this.waitFor(
() => this.byRoleName("tab", /Single AWS Account/),
undefined,
"AWS connect step",
);
}
/** Type the account id and static keys on the AWS one-step connect form. */
async fillAwsAccountKeys({
accountId,
accessKeyId,
secretAccessKey,
}: {
accountId: string;
accessKeyId: string;
secretAccessKey: string;
}): Promise<void> {
const accountInput = await this.waitFor(() =>
this.inputByName("providerUid"),
);
await this.user.fill(accountInput, accountId);
const keyInput = await this.waitFor(() =>
this.inputByName("aws_access_key_id"),
);
await this.user.fill(keyInput, accessKeyId);
const secretInput = await this.waitFor(() =>
this.inputByName("aws_secret_access_key"),
);
await this.user.fill(secretInput, secretAccessKey);
}
/** Submit the AWS one-step form; waits for its action to become enabled. */
async connectAccount(): Promise<void> {
await this.clickPrimary(/Connect account/);
}
/** Wait until the connection test step is showing with its action ready. */
async waitForConnectionTestStep(): Promise<void> {
await this.waitForButton(/Check connection/, 10000);
}
/** Switch back to a single account from the organization flow's tabs. */
async switchToAwsSingleAccount(): Promise<void> {
const tab = await this.waitFor(
() => this.byRoleName("tab", /Single AWS Account/),
undefined,
"Single AWS Account tab",
);
await this.user.click(tab);
}
/** Select GCP and open the GCP Organization method card (no advance wait). */
async chooseGcpOrganizationsMethod(): Promise<void> {
await this.selectProviderType(/Google Cloud Platform/);
await this.chooseMethod(/Add Multiple Projects With GCP Organization/);
}
/** Enter the GCP Organization onboarding flow from a fresh wizard. */
async chooseGcpOrganizations(): Promise<void> {
await this.chooseGcpOrganizationsMethod();
await this.waitForText(/Organization Details/);
}
/** Select Azure and open the Management Group method card (no advance wait). */
async chooseAzureOrganizationsMethod(): Promise<void> {
await this.selectProviderType(/Microsoft Azure/);
await this.chooseMethod(
/Add Multiple Subscriptions With Azure Management Group/,
);
}
/** Enter the Azure Management Group onboarding flow from a fresh wizard. */
async chooseAzureOrganizations(): Promise<void> {
await this.chooseAzureOrganizationsMethod();
await this.waitForText(/Organization Details/);
}
/** Wait until the wizard shows the selected provider's method fork. */
async waitForMethodStep(): Promise<void> {
await this.waitForText(/Select a method to add your/);
}
/** Whether the organization setup step is showing (the flow actually started). */
hasOrganizationSetupStep(): boolean {
return this.containsText(/Organization Details/);
}
/** Whether the step links out to the given documentation URL fragment. */
hasDocsLinkTo(urlFragment: string): boolean {
return this.q(`a[href*="${urlFragment}"]`) !== null;
}
// --- Wizard: GCP setup step --------------------------------------------
async fillGcpOrgDetails(orgId: string, name?: string): Promise<void> {
const orgIdInput = await this.waitFor(() => this.inputByName("gcpOrgId"));
await this.user.fill(orgIdInput, orgId);
if (name !== undefined) {
const nameInput = this.inputByName("organizationName");
if (nameInput) await this.user.fill(nameInput, name);
}
}
/** Paste a service-account key JSON into the GCP authentication step. */
async fillGcpServiceAccountKey(json: string): Promise<void> {
const textarea = await this.waitFor(
() =>
this.q(
'textarea[name="serviceAccountKey"]',
) as HTMLTextAreaElement | null,
);
await this.user.fill(textarea, json);
}
// --- Wizard: Azure setup step ------------------------------------------
/** Fill the Azure organization-details phase (the tenant is the only id asked for). */
async fillAzureOrgDetails(tenantId: string, name?: string): Promise<void> {
const tenantInput = await this.waitFor(() => this.inputByName("tenantId"));
await this.user.fill(tenantInput, tenantId);
if (name !== undefined) {
const nameInput = this.inputByName("organizationName");
if (nameInput) await this.user.fill(nameInput, name);
}
}
/** Fill the Azure service-principal credentials on the authentication step. */
async fillAzureCredentials(
clientId: string,
clientSecret: string,
): Promise<void> {
const clientIdInput = await this.waitFor(() =>
this.inputByName("clientId"),
);
await this.user.fill(clientIdInput, clientId);
const secretInput = await this.waitFor(() =>
this.inputByName("clientSecret"),
);
await this.user.fill(secretInput, clientSecret);
}
// --- Wizard: AWS setup step --------------------------------------------
async fillAwsOrgDetails(orgId: string, name?: string): Promise<void> {
const orgIdInput = await this.waitFor(() => this.inputByName("awsOrgId"));
await this.user.fill(orgIdInput, orgId);
if (name !== undefined) {
const nameInput = this.inputByName("organizationName");
if (nameInput) await this.user.fill(nameInput, name);
}
}
/** Advance from the organization-details step to the authentication step. */
async submitOrganizationDetails(): Promise<void> {
await this.clickPrimary(/Next/);
}
/** Click the setup step's primary footer button ("Next" / "Authenticate"). */
private async clickPrimary(name: RegExp): Promise<void> {
const btn = await this.waitForButton(name);
await this.user.click(btn);
}
async fillAwsAccess({
ouId,
roleArn,
}: {
ouId: string;
roleArn: string;
}): Promise<void> {
const ouInput = await this.waitFor(() =>
this.inputByName("organizationalUnitId"),
);
await this.user.fill(ouInput, ouId);
const roleInput = await this.waitFor(() => this.inputByName("roleArn"));
await this.user.fill(roleInput, roleArn);
// Confirm the StackSet-deployed checkbox.
const checkbox = await this.waitFor(() =>
this.q('#stackSetDeployed, [name="stackSetDeployed"]'),
);
await this.user.click(checkbox);
}
/** Submit the authentication step, kicking off account discovery. */
async authenticate(): Promise<void> {
await this.clickPrimary(/Authenticate/);
}
// --- Wizard: credential replacement + discovery recovery -----------------
/** Wait until the confirm-before-replace credential warning is showing. */
async waitForCredentialReplaceWarning(): Promise<void> {
await this.waitForText(/Replace existing credentials\?/);
}
/** Whether the warning states how many providers a replacement re-authenticates. */
hasCredentialReplaceProviderCount(providerCount: number): boolean {
return this.containsText(
new RegExp(`re-authenticates its ${providerCount} providers?`),
);
}
/** Confirm the credential replacement and continue the setup chain. */
async confirmCredentialReplace(): Promise<void> {
await this.clickButton(/Replace credentials/);
}
/** Wait until the app replaced (PATCHed) the organization secret. */
async waitForSecretReplace(): Promise<void> {
await this.waitForRequest("PATCH", "/organization-secrets/");
}
/**
* Whether the pre-apply warning states how many already-onboarded candidates the
* apply would overwrite, and names them — in the noun this organization type uses.
*/
private hasOverwriteWarningFor(
count: number,
names: string[],
noun: string,
): boolean {
const states = this.containsText(
new RegExp(
`overwrite the credentials of ${count} already-onboarded ${noun}`,
),
);
return states && names.every((name) => this.containsText(new RegExp(name)));
}
hasApplyOverwriteWarning(
projectCount: number,
names: string[] = [],
): boolean {
return this.hasOverwriteWarningFor(projectCount, names, "project");
}
/** The same warning, in Azure's noun. */
hasApplySubscriptionOverwriteWarning(
subscriptionCount: number,
names: string[] = [],
): boolean {
return this.hasOverwriteWarningFor(
subscriptionCount,
names,
"subscription",
);
}
/** Confirm the pre-apply credential overwrite and continue into apply. */
async confirmApplyOverwrite(): Promise<void> {
await this.clickButton(/Replace and continue/);
}
/** Wait until a failed discovery surfaces its authentication error. */
async waitForDiscoveryFailure(timeoutMs = 15000): Promise<void> {
await this.waitForText(/Authentication failed/, timeoutMs);
}
/**
* Wait until a failed discovery surfaces the actionable copy for its machine
* code, rather than the type's generic authentication-failure fallback.
*/
async waitForDiscoveryFailureReason(
reason: RegExp,
timeoutMs = 15000,
): Promise<void> {
await this.waitForText(reason, timeoutMs);
}
/** Whether that reason is showing — the negative half of the assertion. */
hasDiscoveryFailureReason(reason: RegExp): boolean {
return this.containsText(reason);
}
/** Retry a failed/timed-out discovery with a fresh one. */
async retryDiscovery(): Promise<void> {
await this.clickButton(/Retry discovery/);
}
/** Wait until the app has triggered at least `n` discoveries. */
async waitForDiscoveryCount(n: number, timeoutMs = 15000): Promise<void> {
await this.waitForRequest("POST", "/discover", n, timeoutMs);
}
// --- Wizard: selection step --------------------------------------------
private get tree(): HTMLElement | null {
return this.q('[role="tree"]');
}
private get treeItems(): HTMLElement[] {
return Array.from(
this.container.querySelectorAll<HTMLElement>('[role="treeitem"]'),
);
}
private treeItemByText(text: RegExp): HTMLElement | null {
return this.treeItems.find((el) => text.test(el.textContent ?? "")) ?? null;
}
// Noun-agnostic on purpose: the copy says "accounts" for AWS, "projects" for
// GCP, "subscriptions" for Azure.
private selectedCountText(): string {
return (
this.container.textContent?.match(
/\d+ of \d+ (?:accounts|projects|subscriptions) selected/,
)?.[0] ?? ""
);
}
private hasSelectionSummary(
selected: number,
total: number,
noun: string,
): boolean {
return new RegExp(`${selected} of ${total} ${noun} selected`).test(
this.selectedCountText(),
);
}
async waitForSelectionTree(): Promise<HTMLElement> {
return this.waitFor(() => this.tree);
}
/**
* A container row's uid: a GCP folder ref, an AWS OU id, or an Azure Management
* Group resource id. Anchored because it is matched against one element's own
* value — the adjacent uid and name columns would otherwise run together.
*/
private static readonly CONTAINER_UID =
/^(?:folders\/\d+|ou-[\w-]+|\/providers\/Microsoft\.Management\/managementGroups\/[\w.()-]+)$/;
/**
* The canonical uid an id column carries, which `TruncatedId` puts in its
* accessible name; the text fallback covers spans it did not render.
*/
private static columnUid(column: HTMLElement): string {
const uid = column.getAttribute("aria-label") ?? column.textContent ?? "";
return uid.trim();
}
/** A row's id column, found by the uid it resolves to. */
private static idColumn(row: HTMLElement): HTMLElement | null {
return (
Array.from(row.querySelectorAll<HTMLElement>("span")).find((span) =>
ProvidersPageHarness.CONTAINER_UID.test(
ProvidersPageHarness.columnUid(span),
),
) ?? null
);
}
private static containerUid(row: HTMLElement): string | null {
const column = ProvidersPageHarness.idColumn(row);
return column ? ProvidersPageHarness.columnUid(column) : null;
}
private get containerRows(): HTMLElement[] {
return this.treeItems.filter(
(item) => ProvidersPageHarness.containerUid(item) !== null,
);
}
/**
* Container rows carrying this label. Counted over rows, not through
* `treeItemByText` (first match only), so a duplicated container is visible.
*/
countContainerRows(label: string): number {
return this.containerRows.filter((item) =>
(item.textContent ?? "").includes(label),
).length;
}
/** Uids rendered by container rows — a row with an unresolved id has none. */
containerRowUids(): string[] {
return this.containerRows.flatMap(
(item) => ProvidersPageHarness.containerUid(item) ?? [],
);
}
/** Visible text of the id column whose accessible name is `uid`. */
containerIdLabel(uid: string): string | null {
const column = Array.from(
this.container.querySelectorAll<HTMLElement>("[aria-label]"),
).find((el) => el.getAttribute("aria-label") === uid);
return column?.textContent?.trim() ?? null;
}
/** Whether a candidate row is rendered inside the subtree of a container. */
isCandidateNestedUnder(
candidateUid: string,
containerLabel: string,
): boolean {
const container = this.treeItems.find(
(item) =>
(item.textContent ?? "").includes(containerLabel) &&
item.parentElement?.querySelector('[role="group"]') !== null,
);
const group = container?.parentElement?.querySelector('[role="group"]');
return (group?.textContent ?? "").includes(candidateUid);
}
/**
* The note a container row shows when nothing under it can be selected, read
* from the icon's accessible label so the assertion needs no hover.
*/
inertContainerNote(containerLabel: string): string | null {
const row = this.containerRows.find((item) =>
(item.textContent ?? "").includes(containerLabel),
);
// The id column carries `role="img"` too, and comes first in the row, so the
// note is the one matched by its svg.
return (
row?.querySelector('[role="img"]:has(svg)')?.getAttribute("aria-label") ??
null
);
}
/** Whether a container row is inert (present, visibly non-selectable). */
isContainerInert(containerLabel: string): boolean {
const row = this.containerRows.find((item) =>
(item.textContent ?? "").includes(containerLabel),
);
return row?.getAttribute("aria-disabled") === "true";
}
/** Whether a candidate currently has a row in the tree at all. */
isCandidateVisible(uid: string): boolean {
return this.treeItemByText(new RegExp(uid)) !== null;
}
/**
* Click a container row itself (not its checkbox or chevron) to expand or
* collapse it. Dispatched directly rather than through user-event, whose
* actionability check rejects an `aria-disabled` row a real pointer can still
* reach.
*/
async clickContainerRow(containerLabel: string): Promise<void> {
const row = await this.waitFor(
() =>
this.containerRows.find((item) =>
(item.textContent ?? "").includes(containerLabel),
) ?? null,
);
row.click();
}
/**
* Whether a candidate row's id spills out of its column or collides with the
* alias input next to it. Measured from real layout boxes, not class names.
*/
candidateRowOverflows(uid: string): boolean {
const row = this.treeItemByText(new RegExp(uid));
const idText = Array.from(
row?.querySelectorAll<HTMLElement>("span") ?? [],
).find((span) => span.textContent === uid);
const alias = row?.querySelector<HTMLInputElement>(
"input:not([type='checkbox'])",
);
if (!idText || !alias) {
throw new Error(`candidate ${uid} has no id text or no alias input`);
}
const idRect = idText.getBoundingClientRect();
const columnRect = idText.parentElement!.getBoundingClientRect();
// Sub-pixel layout rounding, not overflow.
return (
idRect.right > columnRect.right + 1 ||
idRect.right > alias.getBoundingClientRect().left
);
}
/** Current value of a candidate row's alias input. */
candidateAliasValue(idText: RegExp): string | null {
const item = this.treeItemByText(idText);
const input = item?.querySelector<HTMLInputElement>(
"input:not([type='checkbox'])",
);
return input?.value ?? null;
}
/** Wait until account discovery finishes and the selection summary renders. */
async waitForAccountSelection(timeoutMs = 15000): Promise<void> {
await this.waitForText(/of \d+ accounts selected/, timeoutMs);
}
/** Wait until the summary reads "<selected> of <total> accounts selected". */
async waitForSelectedCount(
selected: number,
total: number,
timeoutMs = 15000,
): Promise<void> {
await this.waitForText(
new RegExp(`${selected} of ${total} accounts selected`),
timeoutMs,
);
}
/** Whether the selection summary currently reads "<selected> of <total>". */
hasSelectedCount(selected: number, total: number): boolean {
return this.hasSelectionSummary(selected, total, "accounts");
}
/** Wait until project discovery finishes and the selection summary renders. */
async waitForProjectSelection(timeoutMs = 15000): Promise<void> {
await this.waitForText(/of \d+ projects selected/, timeoutMs);
}
/** Wait until the summary reads "<selected> of <total> projects selected". */
async waitForSelectedProjectCount(
selected: number,
total: number,
timeoutMs = 15000,
): Promise<void> {
await this.waitForText(
new RegExp(`${selected} of ${total} projects selected`),
timeoutMs,
);
}
/** Whether the summary reads "<selected> of <total> projects selected". */
hasSelectedProjectCount(selected: number, total: number): boolean {
return this.hasSelectionSummary(selected, total, "projects");
}
/** Wait until subscription discovery finishes and the selection summary renders. */
async waitForSubscriptionSelection(timeoutMs = 15000): Promise<void> {
await this.waitForText(/of \d+ subscriptions selected/, timeoutMs);
}
/** Wait until the summary reads "<selected> of <total> subscriptions selected". */
async waitForSelectedSubscriptionCount(
selected: number,
total: number,
timeoutMs = 15000,
): Promise<void> {
await this.waitForText(
new RegExp(`${selected} of ${total} subscriptions selected`),
timeoutMs,
);
}
/** Whether the summary reads "<selected> of <total> subscriptions selected". */
hasSelectedSubscriptionCount(selected: number, total: number): boolean {
return this.hasSelectionSummary(selected, total, "subscriptions");
}
/**
* Whether any visible copy uses the AWS candidate noun, which a GCP flow must
* never say — the negative half of the terminology assertions.
*/
usesAccountWording(): boolean {
return this.containsText(
/accounts selected|Accounts Connected!|accounts under this Organization/,
);
}
/** Toggle a discovered candidate's selection by the text of its tree row. */
async toggleCandidate(idText: RegExp): Promise<void> {
const item = await this.waitFor(() => this.treeItemByText(idText));
const checkbox =
item.querySelector<HTMLElement>('[role="checkbox"]') ?? item;
await this.user.click(checkbox);
}
/** Toggle a discovered account's selection by its UID. */
async toggleAccount(uid: string): Promise<void> {
await this.toggleCandidate(new RegExp(uid));
}
/** Whether a discovered candidate is blocked (disabled, not selectable). */
async isCandidateBlocked(idText: RegExp): Promise<boolean> {
const item = await this.waitFor(() => this.treeItemByText(idText));
return item.getAttribute("aria-disabled") === "true";
}
/** Whether a discovered account is blocked (disabled, not selectable). */
async isAccountBlocked(uid: string): Promise<boolean> {
return this.isCandidateBlocked(new RegExp(uid));
}
/** Type an alias into a candidate's tree-row alias input. */
async setCandidateAlias(idText: RegExp, alias: string): Promise<void> {
const item = await this.waitFor(() => this.treeItemByText(idText));
const input = item.querySelector<HTMLInputElement>(
"input:not([type='checkbox'])",
);
if (!input) throw new Error("no alias input for candidate");
await this.user.fill(input, alias);
}
/**
* What a candidate's row currently shows for its connection test: a settled
* verdict, a spinner, or nothing yet.
*/
candidateConnectionState(
idText: RegExp,
): "success" | "error" | "testing" | "none" {
const item = this.treeItemByText(idText);
if (!item) return "none";
if (item.querySelector('[aria-label="Success"]')) return "success";
if (item.querySelector('[aria-label="Error"]')) return "error";
if (item.querySelector('[aria-label="Loading"]')) return "testing";
return "none";
}
/** Wait until a candidate's row settles on the given connection verdict. */
async waitForCandidateConnectionState(
idText: RegExp,
state: "success" | "error",
timeoutMs = 20000,
): Promise<void> {
await this.waitFor(
() => this.candidateConnectionState(idText) === state,
timeoutMs,
);
}
/** The `aria-checked` state of a tree row's checkbox (e.g. "mixed"). */
candidateCheckboxState(idText: RegExp): string | null {
const item = this.treeItemByText(idText);
const checkbox = item?.querySelector<HTMLElement>('[role="checkbox"]');
return checkbox?.getAttribute("aria-checked") ?? null;
}
async testConnections(): Promise<void> {
await this.clickPrimary(/Test Connections/);
}
async skipValidation(): Promise<void> {
const btn = await this.waitForButton(/Skip Connection Validation/);
await this.user.click(btn);
}
async goBack(): Promise<void> {
const btn = await this.waitForButton(/^\s*Back\s*$/);
await this.user.click(btn);
}
/** Wait until every selected account has connected successfully. */
async waitForAccountsConnected(timeoutMs = 20000): Promise<void> {
await this.waitForText(/Accounts Connected!/, timeoutMs);
}
/** Wait until every selected project has connected successfully. */
async waitForProjectsConnected(timeoutMs = 20000): Promise<void> {
await this.waitForText(/Projects Connected!/, timeoutMs);
}
/** Wait until every selected subscription has connected successfully. */
async waitForSubscriptionsConnected(timeoutMs = 20000): Promise<void> {
await this.waitForText(/Subscriptions Connected!/, timeoutMs);
}
/** Wait until the flow reaches the connected / ready-to-scan state. */
async waitForReadyToScan(timeoutMs = 20000): Promise<void> {
await this.waitForText(/Accounts Connected!|ready to Scan/, timeoutMs);
}
/** Wait until the connection-test error alert surfaces (partial failure). */
async waitForConnectionError(timeoutMs = 20000): Promise<void> {
await this.waitForText(
/problem connecting to some accounts|No accounts connected/,
timeoutMs,
);
}
/** Wait until the app has issued at least `n` connection-test requests. */
async waitForConnectionAttempts(n: number, timeoutMs = 20000): Promise<void> {
await this.waitFor(() => this.connectionCallCount >= n, timeoutMs);
}
/** Wait until the app has issued at least `n` apply requests. */
async waitForApplyCount(n: number, timeoutMs = 20000): Promise<void> {
await this.waitFor(() => this.applyCallCount >= n, timeoutMs);
}
// --- Wizard: launch step ------------------------------------------------
/** Tick "launch an initial scan now" on the launch step's schedule form. */
async enableInitialScan(): Promise<void> {
const checkbox = await this.waitFor(() =>
this.q(`[role="checkbox"][aria-label="${INITIAL_SCAN_LABEL}"]`),
);
await this.clickElement(checkbox);
}
/** Save the scan schedules and launch the initial scans (footer action). */
async saveScheduleAndLaunch(): Promise<void> {
await this.clickPrimary(/Save and launch scan/);
}
/** Wait until the schedules saved and the initial scans were launched. */
async waitForLaunchComplete(timeoutMs = 20000): Promise<void> {
await this.waitForText(
/Scan schedules saved and initial scans launched/,
timeoutMs,
);
}
/** Wait for the "saved for some, failed for others" report. */
async waitForPartialScheduleSave(
saved: number,
failed: number,
timeoutMs = 20000,
): Promise<void> {
await this.waitForText(
new RegExp(
`saved for ${saved} \\w+, but ${failed} \\w+ could not be updated`,
),
timeoutMs,
);
}
/** Wait for the report that no schedule could be saved at all. */
async waitForScheduleSaveFailure(timeoutMs = 20000): Promise<void> {
await this.waitForText(/could not be saved for/, timeoutMs);
}
/** Whether the reason the API gave for a failure reached the user. */
hasScheduleFailureReason(reason: string): boolean {
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/);
}
// --- Table: grouping + row actions --------------------------------------
private get tableRows(): HTMLElement[] {
return Array.from(this.container.querySelectorAll<HTMLElement>("tr"));
}
/** The table row (`<tr>`) whose text matches — a provider or group row. */
private rowByText(text: RegExp): HTMLElement | null {
return this.tableRows.find((r) => text.test(r.textContent ?? "")) ?? null;
}
private async waitForRow(
text: RegExp,
timeoutMs = 5000,
): Promise<HTMLElement> {
return this.waitFor(() => this.rowByText(text), timeoutMs);
}
/** Whether a provider row addressed by its alias is present. */
hasProviderRow(alias: string): boolean {
return this.rowByText(new RegExp(alias)) !== null;
}
/** Wait until a provider row addressed by its alias is present. */
async waitForProviderRow(alias: string): Promise<HTMLElement> {
return this.waitForRow(new RegExp(alias));
}
/** Whether the organization group row is present. */
hasOrganizationRow(name: string): boolean {
return this.rowByText(new RegExp(name)) !== null;
}
/** Wait until the organization group row is present. */
async waitForOrganizationRow(name: string): Promise<HTMLElement> {
return this.waitForRow(new RegExp(name));
}
/** Wait until a node (OU / folder) group row is present. */
async waitForNodeGroup(name: string): Promise<HTMLElement> {
return this.waitForRow(new RegExp(name));
}
/** Whether a node group is labelled by its kind (e.g. "Organizational Unit"). */
hasNodeKindLabel(label: string): boolean {
return this.containsText(new RegExp(label));
}
/** Whether the organization row surfaces its total provider count. */
hasProviderCount(count: number): boolean {
return this.containsText(new RegExp(`${count} Providers`));
}
// --- Table: degraded-hierarchy notice ------------------------------------
/** Wait until the notice for a failed hierarchy fetch surfaces. */
async waitForDegradedHierarchyNotice(): Promise<void> {
await this.waitForText(/Organization grouping is incomplete/);
}
/** Whether the degraded-hierarchy notice is present. */
hasDegradedHierarchyNotice(): boolean {
return this.containsText(/Organization grouping is incomplete/);
}
/** Whether the notice warns that providers may be ungrouped. */
hasUngroupedProvidersNotice(): boolean {
return this.containsText(/Some providers may appear ungrouped/);
}
/** Open the row-actions dropdown for the organization named `name`. */
private async openActionsFor(name: string): Promise<void> {
const trigger = await this.waitFor(() => {
const row = this.rowByText(new RegExp(name));
if (!row) return null;
return (
Array.from(row.querySelectorAll<HTMLButtonElement>("button")).find(
(b) => /open actions menu/i.test(b.getAttribute("aria-label") ?? ""),
) ?? null
);
});
await this.user.click(trigger);
}
/**
* Dismiss the open row-actions menu and wait until it is gone. The menu is
* modal, so leaving it open makes the next row's trigger click land on the
* dismiss layer and the reader below re-read the previous row's items.
*/
private async closeActionsMenu(): Promise<void> {
await this.user.keyboard("{Escape}");
await this.waitFor(() => this.q('[role="menu"]') === null);
}
/** The row-action labels offered for the organization named `name`. */
async actionLabelsFor(name: string): Promise<string[]> {
await this.openActionsFor(name);
const menu = await this.waitFor(() => this.q('[role="menu"]'));
const labels = Array.from(
menu.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).map((item) => item.textContent?.trim() ?? "");
await this.closeActionsMenu();
return labels;
}
/** Open the "Edit Organization Name" flow for the organization `name`. */
async openEditNameFor(name: string): Promise<void> {
await this.openActionsFor(name);
await this.clickMenuItem(/Edit Organization Name/);
}
/** Open the "Update Credentials" flow for the organization `name`. */
async openUpdateCredentialsFor(name: string): Promise<void> {
await this.openActionsFor(name);
await this.clickMenuItem(/Update Credentials/);
}
/** Open the "Delete Organization" flow for the organization `name`. */
async openDeleteFor(name: string): Promise<void> {
await this.openActionsFor(name);
await this.clickMenuItem(/Delete Organization/);
}
/**
* Open the delete flow for a folder — the GCP counterpart of `openDeleteFor`,
* which says "Organizational Unit".
*/
async openDeleteFolderFor(name: string): Promise<void> {
await this.openActionsFor(name);
await this.clickMenuItem(/Delete Folder/);
}
/** Open the delete flow for an Azure Management Group node. */
async openDeleteManagementGroupFor(name: string): Promise<void> {
await this.openActionsFor(name);
await this.clickMenuItem(/Delete Management Group/);
}
/** Wait until the wizard re-opens on the AWS authentication step. */
async waitForAuthenticationStep(): Promise<void> {
await this.waitForText(
/Amazon Web Services \(AWS\) \/ Authentication Details/,
);
}
/** Whether the authentication step's primary button is present. */
hasAuthenticateButton(): boolean {
return this.buttonByText(/Authenticate/) !== null;
}
/** Whether a "Back" button is present. */
hasBackButton(): boolean {
return this.buttonByText(/^\s*Back\s*$/) !== null;
}
/** Wait until the delete-confirmation dialog surfaces. */
async waitForDeleteConfirmation(): Promise<void> {
await this.waitForText(/Are you absolutely sure/);
}
/** Whether the delete dialog shows its permanent-deletion warning. */
hasDeleteWarning(): boolean {
return this.hasDeleteWarningFor("organization");
}
/**
* Whether the dialog warns about permanently deleting the given entity, whose
* label follows the node kind ("folder" for GCP, "organizational unit" for AWS).
*/
hasDeleteWarningFor(entityLabel: string): boolean {
return this.containsText(
new RegExp(`permanently delete this ${entityLabel}`),
);
}
/** Whether the dialog states how many providers the deletion cascades to. */
hasCascadeWarning(providerCount: number): boolean {
return this.containsText(
new RegExp(`cascade to its ${providerCount} providers?`),
);
}
/** Confirm the delete-organization dialog. */
async confirmDelete(): Promise<void> {
await this.clickButton(/^\s*Delete\s*$/);
}
/** Wait until the app has issued the given request (defaults to at least one). */
protected async waitForRequest(
method: string,
pathIncludes: string,
count = 1,
timeoutMs = 15000,
): Promise<void> {
await this.waitFor(
() => this.countRequests(method, pathIncludes) >= count,
timeoutMs,
);
}
/** Wait until the app has PATCHed (renamed) the given organization. */
async waitForOrganizationRename(orgId: string): Promise<void> {
await this.waitForRequest("PATCH", `/organizations/${orgId}`);
}
/** Wait until the app has issued the DELETE for the given organization. */
async waitForOrganizationDelete(orgId: string): Promise<void> {
await this.waitForRequest("DELETE", `/organizations/${orgId}`);
}
/** Wait until the app has issued the DELETE for the given hierarchy node. */
async waitForNodeDelete(nodeId: string): Promise<void> {
await this.waitForRequest("DELETE", `/organization-nodes/${nodeId}`);
}
/** How many times the app polled a task (`GET /tasks/:id`). */
get taskPollCount(): number {
return this.countRequests("GET", "/tasks/");
}
/** Wait until the app polled the deletion task the API answered 202 with. */
async waitForTaskPoll(taskIdPrefix = ""): Promise<void> {
await this.waitForRequest("GET", `/tasks/${taskIdPrefix}`);
}
/**
* Wait until the deletion is reported as accepted — not "removed successfully",
* since the polled task completes on dispatch, not on completion.
*/
async waitForDeletionAccepted(timeoutMs = 15000): Promise<void> {
await this.waitForText(/Deletion started/, timeoutMs);
}
/** Whether any copy overclaims a deletion that is only under way. */
claimsDeletionFinished(): boolean {
return this.containsText(/removed successfully|was deleted/);
}
/** Wait until a failed deletion task is reported instead of a false success. */
async waitForDeletionFailure(timeoutMs = 15000): Promise<void> {
await this.waitForText(/Deletion did not complete/, timeoutMs);
}
// --- Table: edit-name modal ---------------------------------------------
private get editNameInput(): HTMLInputElement | null {
return this.q("#edit-name-input") as HTMLInputElement | null;
}
/** Wait until the inline edit-name modal is open. */
async waitForEditNameModal(): Promise<void> {
await this.waitForText(/Edit Organization Name/);
}
async fillEditName(value: string): Promise<void> {
const input = await this.waitFor(() => this.editNameInput);
await this.user.fill(input, value);
}
/** Save the inline edit-name modal. */
async saveName(): Promise<void> {
await this.clickButton(/^\s*Save\s*$/);
}
}