feat(ui): GCP org onboarding — canonical contract + shared lifecycle (#12255)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-07-31 13:30:46 +02:00
committed by GitHub
co-authored by Pablo F.G
parent b56df840fc
commit 2db3bebd15
90 changed files with 10223 additions and 1979 deletions
+2 -2
View File
@@ -181,9 +181,9 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Run browser tests
- name: Run integration tests
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run test:browser
run: pnpm run test:integration
- name: Build application
if: steps.check-changes.outputs.any_changed == 'true'
+1
View File
@@ -8,6 +8,7 @@
# testing
/coverage
__screenshots__/
.vitest-attachments/
# next.js
/.next/
+57
View File
@@ -0,0 +1,57 @@
/**
* Shared client shell for browser-mode page tests.
*
* Mirrors the production shell: `app/(prowler)/layout.tsx` (the source of truth
* — keep this in step with it) renders `<Providers themeProps={{ attribute:
* "class", defaultTheme: "dark" }}>` — i.e. `app/providers.tsx`'s
* `SessionProvider` + `next-themes` provider — with `<Toaster />` mounted inside
* it. Pages under test therefore get the same session/theme context and the same
* toast host they get in production, instead of each harness hand-rolling a
* subset.
*
* Mirrored rather than composed from `app/providers.tsx` on purpose: that
* component hardcodes a session-less `SessionProvider`, which fetches
* `/api/auth/session` on mount. There is no Next auth route in browser mode and
* MSW is configured with `onUnhandledRequest: "error"`, so that request fails
* the test. Wrapping it in an outer `SessionProvider` doesn't help — the inner,
* session-less one is the provider the tree actually consumes.
*/
import type { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "next-themes";
import type { PropsWithChildren } from "react";
import { Toaster } from "@/components/shadcn/toast/Toaster";
const TENANT_ID = "11111111-2222-4333-8444-555555555555";
/**
* Default fake session. Supplying one keeps `SessionProvider` from fetching
* `/api/auth/session`; the token is what the server actions send to MSW. Typed
* as the app's augmented `Session` (see `nextauth.d.ts`) so a change to the
* fields the pages read fails here instead of at runtime.
*/
const TEST_SESSION: Session = {
tenantId: TENANT_ID,
accessToken: "test-access-token",
expires: "2999-01-01T00:00:00Z",
};
interface TestAppShellProps extends PropsWithChildren {
/** Override the default fake session (e.g. a different tenant). */
session?: Session;
}
export function TestAppShell({
children,
session = TEST_SESSION,
}: TestAppShellProps) {
return (
<SessionProvider session={session}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
<Toaster />
</ThemeProvider>
</SessionProvider>
);
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Base class for browser-mode page test harnesses.
*
* Owns the generic DOM / wait / interaction plumbing every page harness needs,
* so concrete harnesses (providers, attack-paths, …) only declare their own
* domain vocabulary. The DOM / wait / interaction primitives are `protected` —
* subclasses build their semantic API on top of them and tests don't reach
* them directly. The public members are the deliberate exceptions: `user`
* (harness tests spy on it) and the request-tracking assertion helpers
* (`requestLog`, `countRequests`, `lastRequestBody`) that page harnesses expose
* as domain vocab.
*
* Mount-agnostic on purpose: some pages are mounted by their harness, others
* (attack-paths) are rendered by the test directly, so a `render` here would
* only serve half the call sites. Mounting lives in `render-browser.tsx`, which
* wraps every render in the shared app shell (`app-shell.tsx`) — both kinds of
* call site reach it.
*
* Request tracking is opt-in via `trackRequests(worker)`, and unregisters
* itself when the test ends.
*/
import type { SetupWorker } from "msw/browser";
import { onTestFinished, vi } from "vitest";
import { userEvent } from "vitest/browser";
type RequestStartListener = (event: { request: Request }) => void;
export abstract class BrowserHarness<TFixture> {
readonly user = userEvent;
/**
* Every request MSW saw since `trackRequests` was wired, for assertions. The
* entry keeps a clone, so a payload assertion can read a body the app's own
* fetch already consumed.
*/
readonly requestLog: Array<{
method: string;
url: string;
request: Request;
}> = [];
private trackedWorker: SetupWorker | null = null;
private requestListener: RequestStartListener | null = null;
constructor(readonly fixture: TFixture) {}
// --- Request tracking (opt-in) ------------------------------------------
/** Start recording MSW requests into `requestLog`. Call once, after mounting. */
protected trackRequests(worker: SetupWorker): void {
const listener: RequestStartListener = ({ request }) => {
this.requestLog.push({
method: request.method,
url: request.url,
request: request.clone(),
});
};
this.trackedWorker = worker;
this.requestListener = listener;
worker.events.on("request:start", listener);
// The worker is module-level and shared across harnesses, so listeners
// would otherwise accumulate run over run. Drop only this harness's
// listener when the test ends — clearing the emitter would also silence
// listeners another harness or diagnostic owns.
onTestFinished(() => this.untrackRequests());
}
private untrackRequests(): void {
const worker = this.trackedWorker;
const listener = this.requestListener;
if (!worker || !listener) return;
worker.events.removeListener("request:start", listener);
this.trackedWorker = null;
this.requestListener = null;
}
countRequests(method: string, pathIncludes: string): number {
return this.requestLog.filter(
(r) => r.method === method && r.url.includes(pathIncludes),
).length;
}
/** Parsed JSON body of the most recent request matching method + path. */
async lastRequestBody<T = unknown>(
method: string,
pathIncludes: string,
): Promise<T | null> {
const entry = [...this.requestLog]
.reverse()
.find((r) => r.method === method && r.url.includes(pathIncludes));
return entry ? ((await entry.request.clone().json()) as T) : null;
}
// --- Low-level DOM ------------------------------------------------------
protected get container(): HTMLElement {
return document.body;
}
protected q(selector: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(selector);
}
protected byRoleName(
role: string,
name: RegExp,
scope: ParentNode = document,
): HTMLElement | null {
const explicit = Array.from(
scope.querySelectorAll<HTMLElement>(`[role="${role}"]`),
).find((el) => name.test(el.textContent ?? ""));
if (explicit) return explicit;
// A native <button> exposes role "button" implicitly, without the
// attribute — so it isn't matched by the `[role="button"]` query above.
if (role === "button") {
return (
Array.from(scope.querySelectorAll<HTMLElement>("button")).find(
(el) => !el.hasAttribute("role") && name.test(el.textContent ?? ""),
) ?? null
);
}
return null;
}
protected buttonByText(
name: RegExp,
scope: ParentNode = document,
): HTMLButtonElement | null {
return (
Array.from(scope.querySelectorAll<HTMLButtonElement>("button")).find(
(b) => name.test(b.textContent ?? ""),
) ?? null
);
}
protected inputByName(name: string): HTMLInputElement | null {
return this.q(`input[name="${name}"]`) as HTMLInputElement | null;
}
protected containsText(pattern: RegExp): boolean {
return pattern.test(this.container.textContent ?? "");
}
// --- Sync helpers -------------------------------------------------------
/** Wait until the predicate returns truthy and return that value. */
protected async waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs = 5000,
intervalMs = 30,
): Promise<T> {
return vi.waitFor(
() => {
const v = fn();
if (!v) throw new Error("waitFor predicate not yet truthy");
return v;
},
{ timeout: timeoutMs, interval: intervalMs },
) as Promise<T>;
}
protected async waitForText(
pattern: RegExp,
timeoutMs = 5000,
): Promise<void> {
await this.waitFor(() => this.containsText(pattern), timeoutMs);
}
protected async waitForButton(
name: RegExp,
timeoutMs = 5000,
): Promise<HTMLButtonElement> {
return this.waitFor(() => {
const btn = this.buttonByText(name);
return btn && !btn.disabled ? btn : null;
}, timeoutMs);
}
/**
* Sleep for a fixed duration to let a CSS/layout transition settle. Public
* because a few flows assert on animation-tail state that has no queryable
* settled signal; prefer waiting on an observable post-condition when one
* exists.
*/
async waitForTransition(ms = 350): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
// --- Interactions -------------------------------------------------------
/** Click via user-event, optionally falling back to a native DOM click. */
protected async clickElement(
element: HTMLElement,
options?: { fallbackToDomClick?: boolean },
): Promise<void> {
try {
await this.user.click(element);
} catch (error) {
if (!options?.fallbackToDomClick) throw error;
element.click();
}
}
protected async clickButton(name: RegExp): Promise<void> {
const btn = await this.waitForButton(name);
await this.user.click(btn);
}
/** Click a dropdown/menu item (rendered in a Radix portal) by its label. */
protected async clickMenuItem(name: RegExp): Promise<void> {
const item = await this.waitFor(() => this.byRoleName("menuitem", name));
await this.user.click(item);
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Vitest fixtures shared by the browser-mode integration tests.
*
* `seedRuntimeConfig` writes the runtime-config data island (`<script
* type="application/json">` in <head>) that `isCloud()` and the other runtime
* readers parse in the browser. There is no Next.js server in browser mode to
* render it, so tests seed it themselves; a test passes only the keys it cares
* about and the production reader (`pickConfig`) fills the rest with defaults.
*
* The fixture is `auto`, so every test gets a default island (`cloudEnabled:
* true`) without opting in, and the island is removed on teardown so nothing
* leaks to the next test. Destructure `seedRuntimeConfig` to override — call it
* before mounting, since the readers are uncached and read at render time. An
* override merges onto the default island, so seeding one key does not silently
* revert the others to the reader's fallback.
*/
import { test as base } from "vitest";
import {
RUNTIME_CONFIG_SCRIPT_ID,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
export type SeedRuntimeConfig = (partial: Partial<RuntimePublicConfig>) => void;
/** Baseline island every test starts from; overrides merge onto it. */
const DEFAULT_CONFIG: Partial<RuntimePublicConfig> = { cloudEnabled: true };
const writeIsland: SeedRuntimeConfig = (partial) => {
document.getElementById(RUNTIME_CONFIG_SCRIPT_ID)?.remove();
const island = document.createElement("script");
island.id = RUNTIME_CONFIG_SCRIPT_ID;
island.type = "application/json";
island.textContent = JSON.stringify({ ...DEFAULT_CONFIG, ...partial });
document.head.append(island);
};
const removeIsland = (): void =>
document.getElementById(RUNTIME_CONFIG_SCRIPT_ID)?.remove();
interface Fixtures {
seedRuntimeConfig: SeedRuntimeConfig;
}
export const test = base.extend<Fixtures>({
seedRuntimeConfig: [
async ({}, use) => {
writeIsland({});
await use(writeIsland);
removeIsland();
},
{ auto: true },
],
});
export const it = test;
@@ -0,0 +1,716 @@
/**
* Shared fixtures for the organization onboarding flow, used by the onboarding
* integration tests and the no-backend dev harness (MSW).
*
* The wire shapes are declared here rather than imported from
* `@/types/organizations` so refactors to that module don't force the mock
* handlers and fixtures to churn. The GCP discovery result is the one exception
* (see `GcpFixtureDiscoveryResult`).
*
* A fixture is a self-contained snapshot of the API "world" a single test
* exercises: seeded organizations/nodes/providers for the providers-page
* hierarchy, a discovery result to serve while polling, an apply outcome, and
* per-provider connection outcomes. Behaviour flags toggle error branches.
*/
import { ORGANIZATION_TYPE } from "@/types/organizations";
import type {
GcpDiscoveredProject,
GcpDiscoveryResult,
} from "@/types/organizations";
import type { TaskState } from "@/types/tasks";
export const DISCOVERY_STATUS_VALUE = {
PENDING: "pending",
RUNNING: "running",
SUCCEEDED: "succeeded",
FAILED: "failed",
} as const;
export type DiscoveryStatusValue =
(typeof DISCOVERY_STATUS_VALUE)[keyof typeof DISCOVERY_STATUS_VALUE];
/** Canonical node kinds (AWS organizational unit, GCP folder). */
export const NODE_KIND = {
ORGANIZATIONAL_UNIT: "organizational-unit",
FOLDER: "folder",
} as const;
export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND];
/**
* `provider_secret_state` and the relation fields carry the canonical values.
* The app doesn't read them yet, so serving them from the mock is harmless;
* they're here for the code that will consume them.
*/
export const PROVIDER_SECRET_STATE = {
WILL_CREATE: "will_create",
WILL_REPLACE: "will_replace",
} as const;
export type ProviderSecretState =
(typeof PROVIDER_SECRET_STATE)[keyof typeof PROVIDER_SECRET_STATE];
export const APPLY_STATUS_VALUE = {
READY: "ready",
BLOCKED: "blocked",
} as const;
export type ApplyStatusValue =
(typeof APPLY_STATUS_VALUE)[keyof typeof APPLY_STATUS_VALUE];
export interface FixtureRegistration {
provider_exists: boolean;
provider_id: string | null;
organization_relation: string;
/** Canonical relation field. */
organization_node_relation: string;
provider_secret_state: ProviderSecretState;
apply_status: ApplyStatusValue;
blocked_reasons: string[];
}
export interface FixtureProvider {
id: string;
provider: string;
uid: string;
alias: string;
connected: boolean | null;
}
export interface FixtureNode {
id: string;
kind: NodeKind;
name: string;
externalId: string;
parentExternalId: string | null;
organizationId: string;
providerIds: string[];
}
export interface FixtureOrganization {
id: string;
orgType: string;
name: string;
externalId: string;
rootExternalId: string | null;
/** Providers attached directly to the organization (not under a node). */
providerIds: string[];
nodeIds: string[];
secretId: string | null;
}
export interface FixtureConnectionOutcome {
connected: boolean;
error?: string;
/**
* Reads that answer `executing` before the task settles, so a test can hold one
* account testing while another has already settled.
*/
executingPolls?: number;
}
/**
* Fixture-side pairing of a discovered candidate with the provider an apply creates
* for it. Not a wire field: the API answers the mapping through the created
* providers' own `uid`.
*/
export interface FixtureCandidateProviderId {
candidateId: string;
providerId: string;
}
export interface FixtureApplyError {
status: number;
detail: string;
}
export interface FixtureApplyOutcome {
createdProviderIds: string[];
providersCreatedCount: number;
providersLinkedCount: number;
nodesCreatedCount: number;
candidateProviderIds: FixtureCandidateProviderId[];
error: FixtureApplyError | null;
}
export interface FixtureDiscovery {
id: string;
status: DiscoveryStatusValue;
/** Raw AWS or GCP discovery result served on the discovery poll. */
result: unknown;
error: string | null;
}
export interface FixtureScheduleBulkOutcome {
/** Ids reported committed; `null` echoes every requested id minus `failed`. */
updated: string[] | null;
failed: Array<{ id: string; error: string }>;
/**
* Body variant: `flat` is what the API really returns, `attributes` the
* serializer-rendered form the client also tolerates, `bare` a body carrying
* neither list (an empty 200/204).
*/
shape: "flat" | "attributes" | "bare";
}
export interface OrgFixture {
organizations: FixtureOrganization[];
nodes: FixtureNode[];
providers: FixtureProvider[];
discovery: FixtureDiscovery | null;
apply: FixtureApplyOutcome;
/** Connection outcomes keyed by provider uid (AWS account id / GCP project). */
connectionByUid: Record<string, FixtureConnectionOutcome>;
/** POST /organization-secrets returns 409 (duplicate). */
duplicateSecret: boolean;
/** Terminal state the deletion task settles into. */
deletionTaskState: TaskState;
/** How `POST /schedules/bulk` answers the launch step. */
scheduleBulk: FixtureScheduleBulkOutcome;
/** Transition window: AWS bodies carry deprecated aliases alongside canonical. */
includeAwsAliases: boolean;
/** Tripwire (task 2.10): when false the deprecated `/organizational-units` routes are unregistered. */
serveDeprecatedRoutes: boolean;
}
const TS = "2026-07-01T10:00:00Z";
const readyRegistration = (
overrides: Partial<FixtureRegistration> = {},
): FixtureRegistration => ({
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organization_node_relation: "link_required",
provider_secret_state: PROVIDER_SECRET_STATE.WILL_CREATE,
apply_status: APPLY_STATUS_VALUE.READY,
blocked_reasons: [],
...overrides,
});
const blockedRegistration = (
reasons: string[],
overrides: Partial<FixtureRegistration> = {},
): FixtureRegistration =>
readyRegistration({
apply_status: APPLY_STATUS_VALUE.BLOCKED,
blocked_reasons: reasons,
...overrides,
});
// --- AWS discovery result --------------------------------------------------
const AWS_ROOT_ID = "r-aws0";
const AWS_OU_PROD = "ou-aws0-prod1111";
const AWS_OU_SANDBOX = "ou-aws0-sand2222";
interface AwsResultOverrides {
blockedAccountId?: string;
replaceAccountIds?: string[];
}
const buildAwsDiscoveryResult = ({
blockedAccountId = "333333333333",
replaceAccountIds = [],
}: AwsResultOverrides = {}) => {
const account = (
id: string,
name: string,
parentId: string,
registration: FixtureRegistration,
) => ({
id,
name,
arn: `arn:aws:organizations::999999999999:account/o-aws0/${id}`,
email: `${name}@example.com`,
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: TS,
parent_id: parentId,
registration,
});
const regFor = (id: string): FixtureRegistration => {
if (id === blockedAccountId) {
return blockedRegistration(["Account is suspended"]);
}
if (replaceAccountIds.includes(id)) {
return readyRegistration({
provider_exists: true,
provider_id: `provider-existing-${id}`,
provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE,
});
}
return readyRegistration();
};
return {
roots: [
{
id: AWS_ROOT_ID,
arn: `arn:aws:organizations::999999999999:root/o-aws0/${AWS_ROOT_ID}`,
name: "Root",
policy_types: [],
},
],
organizational_units: [
{
id: AWS_OU_PROD,
name: "Production",
arn: `arn:aws:organizations::999999999999:ou/o-aws0/${AWS_OU_PROD}`,
parent_id: AWS_ROOT_ID,
},
{
id: AWS_OU_SANDBOX,
name: "Sandbox",
arn: `arn:aws:organizations::999999999999:ou/o-aws0/${AWS_OU_SANDBOX}`,
parent_id: AWS_ROOT_ID,
},
],
accounts: [
account("111111111111", "prod-web", AWS_OU_PROD, regFor("111111111111")),
account("222222222222", "prod-api", AWS_OU_PROD, regFor("222222222222")),
account(
"333333333333",
"sandbox-1",
AWS_OU_SANDBOX,
regFor("333333333333"),
),
],
};
};
// --- GCP discovery result --------------------------------------------------
export const GCP_ORG_ID = "456123789012";
const GCP_FOLDER_A = "folders/1000000001";
const GCP_FOLDER_B = "folders/1000000002";
/**
* The two kinds of folder with nothing selectable in them, both of which real
* organizations hold: no projects at all (Google's own `system-gsuite`), and only
* blocked projects. Neither changes the selectable count.
*/
export const GCP_EMPTY_FOLDER = "folders/1000000003";
export const GCP_EMPTY_FOLDER_NAME = "system-gsuite";
export const GCP_BLOCKED_FOLDER = "folders/1000000004";
export const GCP_BLOCKED_FOLDER_NAME = "Archived";
export const GCP_BLOCKED_FOLDER_PROJECT = "archived-legacy";
/** A project id long enough to fill the fixed-width id column of a tree row. */
export const GCP_LONG_PROJECT_ID = "sys-33751773248373676292";
interface GcpResultOverrides {
/** Project ids whose registration reports `will_replace` (existing provider). */
replaceProjectIds?: string[];
/**
* Adds `GCP_LONG_PROJECT_ID` as a fourth, selectable project. Opt-in, because it
* raises the selectable count that `N of M projects selected` assertions pin.
*/
includeLongIdProject?: boolean;
}
/**
* Pinned to the app's own wire interfaces — a deliberate exception to this file's
* decoupling rule, so a shape verified against the API cannot be re-invented here
* and drift. Registration keeps the fixture's looser value types.
*/
type GcpFixtureDiscoveryResult = Omit<GcpDiscoveryResult, "projects"> & {
projects: (Omit<GcpDiscoveredProject, "registration"> & {
registration: FixtureRegistration;
})[];
};
/**
* The GCP discovery result as the API shapes it: identity is the resource `name`
* (there is no `id` field), a child's `parent` is its parent's `name`, and
* `display_name` is the only human label — a project's `name` is
* `projects/{number}`.
*/
export const buildGcpDiscoveryResult = ({
replaceProjectIds = [],
includeLongIdProject = false,
}: GcpResultOverrides = {}): GcpFixtureDiscoveryResult => {
const project = (
projectId: string,
resourceName: string,
displayName: string,
parent: string,
registration: FixtureRegistration,
) => ({
project_id: projectId,
name: resourceName,
display_name: displayName,
parent,
state: "ACTIVE",
registration,
});
const readyRegFor = (projectId: string): FixtureRegistration =>
replaceProjectIds.includes(projectId)
? readyRegistration({
provider_exists: true,
provider_id: `provider-existing-${projectId}`,
provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE,
})
: readyRegistration();
return {
organization: {
name: `organizations/${GCP_ORG_ID}`,
display_name: "example.com",
},
folders: [
{
name: GCP_FOLDER_A,
display_name: "Engineering",
parent: `organizations/${GCP_ORG_ID}`,
state: "ACTIVE",
},
{
name: GCP_FOLDER_B,
display_name: "Platform",
parent: GCP_FOLDER_A,
state: "ACTIVE",
},
{
name: GCP_EMPTY_FOLDER,
display_name: GCP_EMPTY_FOLDER_NAME,
parent: `organizations/${GCP_ORG_ID}`,
state: "ACTIVE",
},
{
name: GCP_BLOCKED_FOLDER,
display_name: GCP_BLOCKED_FOLDER_NAME,
parent: `organizations/${GCP_ORG_ID}`,
state: "ACTIVE",
},
],
projects: [
project(
"prod-analytics",
"projects/1000000010",
"Prod Analytics",
GCP_FOLDER_A,
readyRegFor("prod-analytics"),
),
project(
"prod-platform",
"projects/1000000011",
"Prod Platform",
GCP_FOLDER_B,
readyRegFor("prod-platform"),
),
project(
"legacy-sandbox",
"projects/1000000012",
"Legacy Sandbox",
`organizations/${GCP_ORG_ID}`,
blockedRegistration(["Project is pending deletion"]),
),
project(
GCP_BLOCKED_FOLDER_PROJECT,
"projects/1000000014",
"Archived Legacy",
GCP_BLOCKED_FOLDER,
blockedRegistration(["Project is pending deletion"]),
),
...(includeLongIdProject
? [
project(
GCP_LONG_PROJECT_ID,
"projects/1000000013",
"System Generated",
GCP_FOLDER_A,
readyRegFor(GCP_LONG_PROJECT_ID),
),
]
: []),
],
};
};
// --- Fixture builders ------------------------------------------------------
/**
* Ids of the providers an apply creates. They must be UUIDs: the launch step's
* `updateSchedulesBulk` validates every id with `z.uuid()` (SSRF guard) and
* bails before issuing `POST /schedules/bulk` if one doesn't parse.
*/
const AWS_CREATED_PROVIDER_IDS = [
"aaaaaaa1-1111-4111-8111-111111111111",
"aaaaaaa2-2222-4222-8222-222222222222",
];
export const GCP_CREATED_PROVIDER_IDS = [
"bbbbbbb1-1111-4111-8111-111111111111",
"bbbbbbb2-2222-4222-8222-222222222222",
];
const emptyApply = (): FixtureApplyOutcome => ({
createdProviderIds: [],
providersCreatedCount: 0,
providersLinkedCount: 0,
nodesCreatedCount: 0,
candidateProviderIds: [],
error: null,
});
const baseFixture = (): OrgFixture => ({
organizations: [],
nodes: [],
providers: [],
discovery: null,
apply: emptyApply(),
connectionByUid: {},
duplicateSecret: false,
deletionTaskState: "completed",
scheduleBulk: { updated: null, failed: [], shape: "flat" },
// Deprecated AWS FIELDS stay in bodies (mirrors production's facade period)…
includeAwsAliases: true,
// …but the deprecated `/organizational-units` ROUTES are gone (Phase 1
// tripwire, task 2.10): with `onUnhandledRequest: "error"`, any lingering
// alias-route call becomes a hard failure, proving the UI is fully canonical.
serveDeprecatedRoutes: false,
});
/**
* A fresh AWS onboarding world: no seeded organization yet (the flow creates
* one), a succeeded discovery with two ready accounts + one blocked account,
* and an apply that creates two providers which then connect successfully.
*/
export const awsOnboardingFixture = (
overrides: Partial<OrgFixture> = {},
): OrgFixture => {
const createdProviderIds = AWS_CREATED_PROVIDER_IDS;
return {
...baseFixture(),
discovery: {
id: "disc-aws-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildAwsDiscoveryResult(),
error: null,
},
apply: {
...emptyApply(),
createdProviderIds,
providersCreatedCount: 2,
nodesCreatedCount: 2,
candidateProviderIds: [
{ candidateId: "111111111111", providerId: createdProviderIds[0] },
{ candidateId: "222222222222", providerId: createdProviderIds[1] },
],
},
connectionByUid: {
"111111111111": { connected: true },
"222222222222": { connected: true },
},
...overrides,
};
};
/** A fresh GCP organization onboarding world (folders + projects). */
export const gcpOnboardingFixture = (
overrides: Partial<OrgFixture> = {},
): OrgFixture => {
const createdProviderIds = GCP_CREATED_PROVIDER_IDS;
return {
...baseFixture(),
discovery: {
id: "disc-gcp-1",
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
result: buildGcpDiscoveryResult(),
error: null,
},
apply: {
...emptyApply(),
createdProviderIds,
providersCreatedCount: 2,
nodesCreatedCount: 2,
candidateProviderIds: [
{ candidateId: "prod-analytics", providerId: createdProviderIds[0] },
{ candidateId: "prod-platform", providerId: createdProviderIds[1] },
],
},
connectionByUid: {
"prod-analytics": { connected: true },
"prod-platform": { connected: true },
},
...overrides,
};
};
/**
* A providers-page hierarchy world with a fully onboarded AWS organization
* (two OUs, three providers). Used for the providers-table grouping tests.
*/
export const awsHierarchyFixture = (
overrides: Partial<OrgFixture> = {},
): OrgFixture => {
const orgId = "org-aws-1";
const providers: FixtureProvider[] = [
{
id: "p-1",
provider: "aws",
uid: "111111111111",
alias: "prod-web",
connected: true,
},
{
id: "p-2",
provider: "aws",
uid: "222222222222",
alias: "prod-api",
connected: true,
},
{
id: "p-3",
provider: "aws",
uid: "333333333333",
alias: "sandbox-1",
connected: false,
},
];
const nodes: FixtureNode[] = [
{
id: "node-aws-prod",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Production",
externalId: AWS_OU_PROD,
parentExternalId: AWS_ROOT_ID,
organizationId: orgId,
providerIds: ["p-1", "p-2"],
},
{
id: "node-aws-sandbox",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Sandbox",
externalId: AWS_OU_SANDBOX,
parentExternalId: AWS_ROOT_ID,
organizationId: orgId,
providerIds: ["p-3"],
},
];
return {
...baseFixture(),
organizations: [
{
id: orgId,
orgType: ORGANIZATION_TYPE.AWS,
name: "My AWS Organization",
externalId: "o-aws0abcdef",
rootExternalId: AWS_ROOT_ID,
providerIds: [],
nodeIds: nodes.map((n) => n.id),
secretId: "secret-aws-1",
},
],
nodes,
providers,
...overrides,
};
};
/** AWS + GCP organizations side by side (mixed-hierarchy display test). */
export const mixedHierarchyFixture = (
overrides: Partial<OrgFixture> = {},
): OrgFixture => {
const aws = awsHierarchyFixture();
const gcpOrgId = "org-gcp-1";
const gcpProviders: FixtureProvider[] = [
{
id: "gp-1",
provider: "gcp",
uid: "prod-analytics",
alias: "Prod Analytics",
connected: true,
},
{
id: "gp-2",
provider: "gcp",
uid: "prod-platform",
alias: "Prod Platform",
connected: true,
},
];
const gcpNodes: FixtureNode[] = [
{
id: "node-gcp-eng",
kind: NODE_KIND.FOLDER,
name: "Engineering",
externalId: GCP_FOLDER_A,
parentExternalId: `organizations/${GCP_ORG_ID}`,
organizationId: gcpOrgId,
providerIds: ["gp-1"],
},
{
id: "node-gcp-platform",
kind: NODE_KIND.FOLDER,
name: "Platform",
externalId: GCP_FOLDER_B,
parentExternalId: GCP_FOLDER_A,
organizationId: gcpOrgId,
providerIds: ["gp-2"],
},
];
return {
...baseFixture(),
organizations: [
...aws.organizations,
{
id: gcpOrgId,
orgType: ORGANIZATION_TYPE.GCP,
name: "My GCP Organization",
externalId: GCP_ORG_ID,
// `root_external_id` is the AWS root OU; a GCP organization has none, and
// its top-level folders are the ones with no parent node.
rootExternalId: null,
providerIds: [],
nodeIds: gcpNodes.map((n) => n.id),
secretId: "secret-gcp-1",
},
],
nodes: [...aws.nodes, ...gcpNodes],
providers: [...aws.providers, ...gcpProviders],
...overrides,
};
};
/**
* An organization of a type the wizard cannot onboard (display-only): it is still
* grouped and labelled from its own `org_type`, but offers no wizard re-entry.
*/
export const displayOnlyOrgHierarchyFixture = (
overrides: Partial<OrgFixture> = {},
): OrgFixture => {
const orgId = "org-azure-1";
return {
...baseFixture(),
organizations: [
{
id: orgId,
orgType: ORGANIZATION_TYPE.AZURE,
name: "Contoso Tenant",
externalId: "11111111-2222-3333-4444-555555555555",
rootExternalId: null,
providerIds: ["ap-1"],
nodeIds: [],
secretId: null,
},
],
nodes: [],
providers: [
{
id: "ap-1",
provider: "azure",
uid: "99999999-8888-7777-6666-555555555555",
alias: "contoso-prod",
connected: true,
},
],
...overrides,
};
};
export const fixtures = {
awsOnboarding: awsOnboardingFixture,
gcpOnboarding: gcpOnboardingFixture,
awsHierarchy: awsHierarchyFixture,
mixedHierarchy: mixedHierarchyFixture,
displayOnlyOrgHierarchy: displayOnlyOrgHierarchyFixture,
};
+695
View File
@@ -0,0 +1,695 @@
/**
* MSW handlers for the organization onboarding flow.
*
* These serve BOTH the deprecated `/organizational-units` routes and the
* canonical `/organization-nodes` routes over the same fixture data. AWS
* bodies carry canonical fields plus the deprecated aliases, mirroring an API
* that still accepts both. Set the fixture flag `serveDeprecatedRoutes` to
* `false` to drop the alias routes — used to assert no UI code still calls
* them.
*
* Wire the handlers per test via `worker.use(...handlersForOrganizations(fx))`.
* The module also doubles as the no-backend dev harness.
*/
import { http, HttpResponse } from "msw";
import { NODE_KIND } from "./organizations.fixtures";
import type {
FixtureNode,
FixtureOrganization,
FixtureProvider,
OrgFixture,
} from "./organizations.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-07-01T10:00:00Z";
type JsonApiError = { errors: Array<{ detail: string; status: string }> };
const errorBody = (detail: string, status: number): JsonApiError => ({
errors: [{ detail, status: String(status) }],
});
const providerRefs = (ids: string[]) =>
ids.map((id) => ({ type: "providers", id }));
interface OrgResourceOptions {
/** Emit the deprecated `organizational_units` alias alongside canonical. */
includeAliases: boolean;
/**
* Node ids to surface under the deprecated `organizational_units` alias.
* Only organizational-unit-kind nodes belong here — the deprecated route
* never surfaced GCP folders.
*/
unitNodeIds: string[];
}
const organizationResource = (
org: FixtureOrganization,
{ includeAliases, unitNodeIds }: OrgResourceOptions,
) => ({
id: org.id,
type: "organizations",
attributes: {
name: org.name,
org_type: org.orgType,
external_id: org.externalId,
metadata: {},
root_external_id: org.rootExternalId,
inserted_at: TS,
updated_at: TS,
},
relationships: {
providers: { data: providerRefs(org.providerIds) },
organization_nodes: {
data: org.nodeIds.map((id) => ({ type: "organization-nodes", id })),
},
// Deprecated alias, gated on `includeAwsAliases`.
...(includeAliases && {
organizational_units: {
data: unitNodeIds.map((id) => ({ type: "organizational-units", id })),
},
}),
},
});
/**
* Canonical `organization-nodes` resource (carries `kind`).
*
* The parent is a relationship, not an attribute, and DJA always emits the key —
* `data: null` for a top-level node, since neither the AWS root nor a GCP
* organization is itself a node. Fixtures still express structure as
* `parentExternalId`, resolved to a node ref here.
*/
const organizationNodeResource = (node: FixtureNode, all: FixtureNode[]) => {
const parent = all.find(
(candidate) =>
candidate.organizationId === node.organizationId &&
candidate.externalId === node.parentExternalId,
);
return {
id: node.id,
type: "organization-nodes",
attributes: {
name: node.name,
kind: node.kind,
external_id: node.externalId,
metadata: {},
inserted_at: TS,
updated_at: TS,
},
relationships: {
organization: {
data: { type: "organizations", id: node.organizationId },
},
parent: {
data: parent ? { type: "organization-nodes", id: parent.id } : null,
},
providers: { data: providerRefs(node.providerIds) },
},
};
};
/** Deprecated AWS-only `organizational-units` resource (no `kind`). */
const organizationalUnitResource = (node: FixtureNode) => ({
id: node.id,
type: "organizational-units",
attributes: {
name: node.name,
external_id: node.externalId,
parent_external_id: node.parentExternalId,
metadata: {},
inserted_at: TS,
updated_at: TS,
},
relationships: {
organization: {
data: { type: "organizations", id: node.organizationId },
},
providers: { data: providerRefs(node.providerIds) },
},
});
/** Single-page collection meta, enough for the paginating list actions to stop. */
const collectionMeta = (count: number) => ({
pagination: { page: 1, pages: 1, count },
version: "v1",
});
/**
* Full `providers` list resource, as the providers-page loader consumes it.
* Deliberately carries no `scan_*` attributes: the API omits them unless a
* schedule is configured, and their absence is what makes the loader fall back
* to `/schedules`.
*/
const providerResource = (provider: FixtureProvider) => ({
id: provider.id,
type: "providers",
attributes: {
provider: provider.provider,
is_dynamic: false,
uid: provider.uid,
alias: provider.alias,
status: "completed",
resources: 0,
connection: {
connected: provider.connected ?? false,
last_checked_at: TS,
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: TS,
updated_at: TS,
created_by: { object: "user", id: "user-1" },
},
relationships: {
secret: { data: { type: "secrets", id: `secret-${provider.id}` } },
provider_groups: { meta: { count: 0 }, data: [] },
},
});
/**
* Serves a collection the way the paginated API does: honours
* `page[number]`/`page[size]` and reports `meta.pagination.pages`, so a caller
* that stops after the first page visibly loses the rest.
*/
const paginatedCollection = <T>(items: T[], request: Request) => {
const params = new URL(request.url).searchParams;
const size = Number(params.get("page[size]")) || items.length || 1;
const page = Number(params.get("page[number]")) || 1;
const start = (page - 1) * size;
return {
data: items.slice(start, start + size),
meta: {
version: "v1",
pagination: {
page,
pages: Math.max(1, Math.ceil(items.length / size)),
count: items.length,
},
},
};
};
/**
* Map a created-provider id back to its uid (AWS account id / GCP project id).
* `apply.candidateProviderIds` is a fixture-side mapping, not a wire field.
*/
const uidForProviderId = (
fx: OrgFixture,
providerId: string,
): string | null => {
const mapping = fx.apply.candidateProviderIds.find(
(m) => m.providerId === providerId,
);
if (mapping) return mapping.candidateId;
const provider = fx.providers.find((p) => p.id === providerId);
return provider?.uid ?? null;
};
/**
* The provider behind an id, seeded or apply-created. A created provider exists
* only as an id plus its candidate mapping, so the rest is synthesized as
* `/providers/:id` does; only `id` and `uid` are ever read back.
*/
const providerForId = (fx: OrgFixture, id: string): FixtureProvider => {
const seeded = fx.providers.find((provider) => provider.id === id);
if (seeded) return seeded;
const uid = uidForProviderId(fx, id) ?? id;
return { id, provider: "aws", uid, alias: uid, connected: true };
};
const applyResultResponse = (fx: OrgFixture) => ({
data: {
id: "apply-result-1",
type: "organization-discovery-apply-results",
attributes: {
providers_created_count: fx.apply.providersCreatedCount,
providers_linked_count: fx.apply.providersLinkedCount,
providers_applied_count:
fx.apply.providersCreatedCount + fx.apply.providersLinkedCount,
organization_nodes_created_count: fx.apply.nodesCreatedCount,
// Deprecated counter alias, gated on `includeAwsAliases`.
...(fx.includeAwsAliases && {
organizational_units_created_count: fx.apply.nodesCreatedCount,
}),
},
relationships: {
providers: {
data: providerRefs(fx.apply.createdProviderIds),
meta: { count: fx.apply.createdProviderIds.length },
},
organization_nodes: {
data: [],
meta: { count: fx.apply.nodesCreatedCount },
},
// Deprecated relationship alias, gated on `includeAwsAliases`.
...(fx.includeAwsAliases && {
organizational_units: {
data: [],
meta: { count: fx.apply.nodesCreatedCount },
},
}),
},
},
// No `included`: the apply view serves provider ids only and rejects `include`,
// so the created providers' uids are read from `/providers` afterwards.
});
const taskResource = (id: string, state: string, result: unknown) => ({
data: { id, type: "tasks", attributes: { state, result } },
});
const CONNECTION_TASK_PREFIX = "conn-task-";
const DELETION_TASK_PREFIX = "del-task-";
interface HandlerOptions {
/**
* Which hierarchy read 500s. The `…Safe` actions turn that into their
* degraded flag and the page derives `hierarchyStatus` — never an injected
* prop.
*/
hierarchyFailure?: HierarchyReadFailure;
}
export const HIERARCHY_READ_FAILURE = {
NONE: "none",
/** Both `/organizations` and `/organization-nodes` fail. */
ALL: "all",
/** Only `/organization-nodes` fails. */
NODES: "nodes",
} as const;
export type HierarchyReadFailure =
(typeof HIERARCHY_READ_FAILURE)[keyof typeof HIERARCHY_READ_FAILURE];
export const handlersForOrganizations = (
fx: OrgFixture,
{ hierarchyFailure = HIERARCHY_READ_FAILURE.NONE }: HandlerOptions = {},
) => {
const organizationReadFails = hierarchyFailure === HIERARCHY_READ_FAILURE.ALL;
const nodeReadFails = hierarchyFailure !== HIERARCHY_READ_FAILURE.NONE;
// Mutable working copy for resources created during the test lifecycle.
const organizations = [...fx.organizations];
const createdSecretIds = new Set(
organizations.map((o) => o.secretId).filter((id): id is string => !!id),
);
let orgSeq = 0;
let secretSeq = 0;
/** Reads per connection task, so `executingPolls` can hold one task running. */
const connectionTaskReads = new Map<string, number>();
const unitNodeIds = (org: FixtureOrganization): string[] =>
org.nodeIds.filter((id) =>
fx.nodes.some(
(n) => n.id === id && n.kind === NODE_KIND.ORGANIZATIONAL_UNIT,
),
);
const orgResource = (org: FixtureOrganization) =>
organizationResource(org, {
includeAliases: fx.includeAwsAliases,
unitNodeIds: unitNodeIds(org),
});
const handlers = [
// --- organizations CRUD + filters ------------------------------------
http.get(`${API}/organizations`, ({ request }) => {
if (organizationReadFails) {
return HttpResponse.json(errorBody("Hierarchy unavailable", 500), {
status: 500,
});
}
const url = new URL(request.url);
const externalId = url.searchParams.get("filter[external_id]");
const orgType = url.searchParams.get("filter[org_type]");
const matches = organizations
.filter((o) => (externalId ? o.externalId === externalId : true))
.filter((o) => (orgType ? o.orgType === orgType : true))
.map(orgResource);
return HttpResponse.json(paginatedCollection(matches, request));
}),
http.post(`${API}/organizations`, async ({ request }) => {
const body = (await request.json()) as {
data?: { attributes?: Record<string, unknown> };
};
const attrs = body?.data?.attributes ?? {};
orgSeq += 1;
const created: FixtureOrganization = {
id: `org-created-${orgSeq}`,
orgType: String(attrs.org_type ?? "aws"),
name: String(attrs.name ?? ""),
externalId: String(attrs.external_id ?? ""),
rootExternalId: null,
providerIds: [],
nodeIds: [],
secretId: null,
};
organizations.push(created);
return HttpResponse.json({ data: orgResource(created) }, { status: 201 });
}),
http.patch<{ id: string }>(
`${API}/organizations/:id`,
async ({ params, request }) => {
const body = (await request.json()) as {
data?: { attributes?: { name?: string } };
};
const org = organizations.find((o) => o.id === params.id);
if (!org) {
return HttpResponse.json(errorBody("Not found", 404), {
status: 404,
});
}
org.name = body?.data?.attributes?.name ?? org.name;
return HttpResponse.json({ data: orgResource(org) });
},
),
http.delete<{ id: string }>(`${API}/organizations/:id`, ({ params }) =>
HttpResponse.json(
taskResource(`${DELETION_TASK_PREFIX}${params.id}`, "executing", null),
{ status: 202 },
),
),
// --- organization-secrets --------------------------------------------
http.get(`${API}/organization-secrets`, ({ request }) => {
const url = new URL(request.url);
const orgId = url.searchParams.get("filter[organization_id]");
const org = organizations.find((o) => o.id === orgId);
const data = org?.secretId
? [
{
id: org.secretId,
type: "organization-secrets",
attributes: { secret_type: "role" },
},
]
: [];
return HttpResponse.json({ data });
}),
http.post(`${API}/organization-secrets`, async ({ request }) => {
const body = (await request.json()) as {
data?: {
attributes?: { secret_type?: string };
relationships?: {
organization?: { data?: { id?: string } };
};
};
};
const orgId = body?.data?.relationships?.organization?.data?.id;
const org = organizations.find((o) => o.id === orgId);
if (fx.duplicateSecret || org?.secretId) {
return HttpResponse.json(
errorBody("A secret for this organization already exists.", 409),
{ status: 409 },
);
}
secretSeq += 1;
const secretId = `secret-created-${secretSeq}`;
createdSecretIds.add(secretId);
if (org) org.secretId = secretId;
return HttpResponse.json(
{
data: {
id: secretId,
type: "organization-secrets",
attributes: {
secret_type: body?.data?.attributes?.secret_type ?? "role",
},
},
},
{ status: 201 },
);
}),
http.patch<{ id: string }>(
`${API}/organization-secrets/:id`,
({ params }) =>
HttpResponse.json({
data: { id: params.id, type: "organization-secrets" },
}),
),
// --- canonical organization-nodes ------------------------------------
http.get(`${API}/organization-nodes`, ({ request }) =>
nodeReadFails
? HttpResponse.json(errorBody("Hierarchy unavailable", 500), {
status: 500,
})
: HttpResponse.json(
paginatedCollection(
fx.nodes.map((node) => organizationNodeResource(node, fx.nodes)),
request,
),
),
),
http.delete<{ id: string }>(`${API}/organization-nodes/:id`, ({ params }) =>
HttpResponse.json(
taskResource(`${DELETION_TASK_PREFIX}${params.id}`, "executing", null),
{ status: 202 },
),
),
// --- discovery -------------------------------------------------------
http.post<{ orgId: string }>(`${API}/organizations/:orgId/discover`, () => {
if (!fx.discovery) {
return HttpResponse.json(errorBody("Discovery unavailable", 409), {
status: 409,
});
}
return HttpResponse.json(
{
data: {
id: fx.discovery.id,
type: "organization-discoveries",
attributes: {
status: "pending",
result: {},
error: null,
inserted_at: TS,
updated_at: TS,
},
},
},
{ status: 202 },
);
}),
http.get<{ orgId: string; discoveryId: string }>(
`${API}/organizations/:orgId/discoveries/:discoveryId`,
({ params }) => {
if (!fx.discovery || fx.discovery.id !== params.discoveryId) {
return HttpResponse.json(errorBody("Discovery not found", 404), {
status: 404,
});
}
return HttpResponse.json({
data: {
id: fx.discovery.id,
type: "organization-discoveries",
attributes: {
status: fx.discovery.status,
result:
fx.discovery.status === "succeeded" ? fx.discovery.result : {},
error: fx.discovery.error,
inserted_at: TS,
updated_at: TS,
},
},
});
},
),
http.post<{ orgId: string; discoveryId: string }>(
`${API}/organizations/:orgId/discoveries/:discoveryId/apply`,
() => {
if (fx.apply.error) {
return HttpResponse.json(
errorBody(fx.apply.error.detail, fx.apply.error.status),
{ status: fx.apply.error.status },
);
}
return HttpResponse.json(applyResultResponse(fx));
},
),
// --- providers-page loader (providers list, groups, schedules) --------
http.get(`${API}/providers`, ({ request }) => {
// `filter[id__in]` is how the apply flow resolves the uids of the providers
// it just created. Those are not seeded in `fx.providers`, so they are
// synthesized here as `/providers/:id` does.
const idFilter = new URL(request.url).searchParams.get("filter[id__in]");
const data = idFilter
? idFilter
.split(",")
.filter(Boolean)
.map((id) => providerResource(providerForId(fx, id)))
: fx.providers.map(providerResource);
return HttpResponse.json({
data,
included: [],
meta: collectionMeta(data.length),
});
}),
http.get(`${API}/provider-groups`, () =>
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
),
// Schedules are a best-effort fallback for providers without `scan_*`
// attributes; none of the fixtures seed one.
http.get(`${API}/schedules`, () =>
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
),
// Cloud-only scan configurations, fetched alongside the view data.
http.get(`${API}/scan-configurations`, () =>
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
),
// --- providers (uid resolution) + connection testing -----------------
http.get<{ id: string }>(`${API}/providers/:id`, ({ params }) => {
const provider = fx.providers.find((p) => p.id === params.id);
const uid = provider?.uid ?? uidForProviderId(fx, params.id) ?? params.id;
return HttpResponse.json({
data: {
id: params.id,
type: "providers",
attributes: {
provider: provider?.provider ?? "aws",
uid,
alias: provider?.alias ?? uid,
connection: {
connected: provider?.connected ?? true,
last_checked_at: TS,
},
},
},
});
}),
http.post<{ id: string }>(`${API}/providers/:id/connection`, ({ params }) =>
HttpResponse.json(
{
data: {
id: `${CONNECTION_TASK_PREFIX}${params.id}`,
type: "tasks",
attributes: { state: "executing" },
},
},
{ status: 202 },
),
),
// --- task polling (deletion + connection) ----------------------------
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
const { taskId } = params;
if (taskId.startsWith(CONNECTION_TASK_PREFIX)) {
const providerId = taskId.slice(CONNECTION_TASK_PREFIX.length);
const uid = uidForProviderId(fx, providerId);
const outcome = uid ? fx.connectionByUid[uid] : undefined;
const reads = (connectionTaskReads.get(taskId) ?? 0) + 1;
connectionTaskReads.set(taskId, reads);
if (outcome?.executingPolls && reads <= outcome.executingPolls) {
return HttpResponse.json(taskResource(taskId, "executing", {}));
}
const connected = outcome?.connected ?? true;
return HttpResponse.json(
taskResource(taskId, "completed", {
connected,
error: connected ? undefined : outcome?.error,
}),
);
}
if (taskId.startsWith(DELETION_TASK_PREFIX)) {
return HttpResponse.json(
taskResource(taskId, fx.deletionTaskState, {}),
);
}
return HttpResponse.json(taskResource(taskId, "completed", {}));
}),
// --- launch (scans + schedules) --------------------------------------
http.post(`${API}/scans`, () =>
HttpResponse.json(
{ data: { id: "scan-1", type: "scans", attributes: {} } },
{ status: 202 },
),
),
http.post(`${API}/schedules`, () =>
HttpResponse.json(
{ data: { id: "schedule-1", type: "schedules", attributes: {} } },
{ status: 202 },
),
),
http.post(`${API}/schedules/bulk`, async ({ request }) => {
const body = (await request.json()) as {
data?: { attributes?: { provider_ids?: string[] } };
};
const requestedIds = body?.data?.attributes?.provider_ids ?? [];
const { failed, shape } = fx.scheduleBulk;
const failedIds = new Set(failed.map((failure) => failure.id));
// `updated` defaults to the requested ids minus the failures: each provider
// commits in its own transaction, so the API's list already excludes them.
const updated =
fx.scheduleBulk.updated ??
requestedIds.filter((id) => !failedIds.has(id));
// The real endpoint returns a plain dict the JSON:API renderer wraps in
// `data`, with no `attributes` level; the other shapes exercise the client's
// tolerance of a serializer-rendered body and of one carrying no lists.
if (shape === "attributes") {
return HttpResponse.json({
data: { type: "schedules-bulk", attributes: { updated, failed } },
});
}
if (shape === "bare") {
return HttpResponse.json({ data: {} });
}
return HttpResponse.json({ data: { updated, failed } });
}),
];
// Deprecated AWS-only routes, served alongside the canonical ones unless
// `serveDeprecatedRoutes` is off.
const deprecatedHandlers = [
http.get(`${API}/organizational-units`, () =>
HttpResponse.json({
data: fx.nodes
.filter((n) => n.kind === NODE_KIND.ORGANIZATIONAL_UNIT)
.map(organizationalUnitResource),
meta: { version: "v1" },
}),
),
http.delete<{ id: string }>(
`${API}/organizational-units/:id`,
({ params }) =>
HttpResponse.json(
taskResource(
`${DELETION_TASK_PREFIX}${params.id}`,
"executing",
null,
),
{ status: 202 },
),
),
];
return fx.serveDeprecatedRoutes
? [...handlers, ...deprecatedHandlers]
: handlers;
};
+6 -1
View File
@@ -1,7 +1,12 @@
import type { ComponentType, PropsWithChildren, ReactElement } from "react";
import { render as vitestRender } from "vitest-browser-react";
const TestProviders = ({ children }: PropsWithChildren) => <>{children}</>;
import { TestAppShell } from "./app-shell";
/** Every browser-mode render gets the shared app shell (see `app-shell.tsx`). */
const TestProviders = ({ children }: PropsWithChildren) => (
<TestAppShell>{children}</TestAppShell>
);
type RenderOptions = Parameters<typeof vitestRender>[1];
@@ -3,18 +3,80 @@ import { describe, expect, it } from "vitest";
import {
APPLY_STATUS,
ApplyStatus,
DiscoveryResult,
AwsDiscoveryResult,
AwsOrgHierarchy,
GcpDiscoveryResult,
GcpOrgHierarchy,
NODE_KIND,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import {
buildAccountLookup,
buildApplyPayload,
buildCandidateLookup,
buildOrgTreeData,
getOuIdsForSelectedAccounts,
getSelectableAccountIds,
getSelectableAccountIdsForTarget,
getNodeIdsForSelectedCandidates,
getSelectableCandidateIds,
getSelectableCandidateIdsForTarget,
mapAwsDiscovery,
mapGcpDiscovery,
} from "./organizations.adapter";
const discoveryFixture: DiscoveryResult = {
// Shaped after the real payload: identity is the resource `name` (there is no
// `id`), a child's `parent` is its parent's `name`, and `display_name` is the
// only human label.
const gcpDiscoveryFixture: GcpDiscoveryResult = {
organization: {
name: "organizations/456123789012",
display_name: "example.com",
},
folders: [
{
name: "folders/1000000001",
display_name: "Engineering",
parent: "organizations/456123789012",
},
{
name: "folders/1000000002",
display_name: "Platform",
parent: "folders/1000000001",
},
],
projects: [
{
project_id: "prod-analytics",
name: "projects/1000000010",
display_name: "Prod Analytics",
parent: "folders/1000000001",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organization_node_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
project_id: "legacy-sandbox",
name: "projects/1000000011",
display_name: "Legacy Sandbox",
parent: "organizations/456123789012",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organization_node_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["Project is pending deletion"],
},
},
],
};
const awsDiscoveryFixture: AwsDiscoveryResult = {
roots: [
{
id: "r-root",
@@ -51,7 +113,7 @@ const discoveryFixture: DiscoveryResult = {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
organization_node_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
@@ -70,8 +132,8 @@ const discoveryFixture: DiscoveryResult = {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "manual_required",
organization_node_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["role_missing"],
},
@@ -89,10 +151,159 @@ const discoveryFixture: DiscoveryResult = {
],
};
describe("buildOrgTreeData", () => {
it("builds nested tree structure and marks blocked accounts as disabled", () => {
// The normalized model is the store currency; every downstream function
// consumes it. Ingestion happens once here.
const hierarchy = mapAwsDiscovery(awsDiscoveryFixture);
describe("mapAwsDiscovery", () => {
it("normalizes the AWS wire result into the common hierarchy model", () => {
expect(hierarchy.orgType).toBe(ORGANIZATION_TYPE.AWS);
expect(hierarchy.organization).toEqual({ uid: "r-root", name: "Root" });
// OUs become nodes with the organizational-unit kind, preserving parentage.
expect(hierarchy.nodes).toEqual([
{
id: "ou-parent",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Parent OU",
parentId: "r-root",
},
{
id: "ou-child",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Child OU",
parentId: "ou-parent",
},
]);
// Accounts become candidates keyed by their provider uid (account id).
expect(hierarchy.candidates.map((candidate) => candidate.uid)).toEqual([
"111111111111",
"222222222222",
"333333333333",
]);
expect(hierarchy.candidates[0]).toMatchObject({
uid: "111111111111",
label: "App Account",
parentId: "ou-child",
});
});
});
describe("mapGcpDiscovery", () => {
it("normalizes the GCP wire result into the common hierarchy model", () => {
// Given / When
const treeData = buildOrgTreeData(discoveryFixture);
const gcpHierarchy = mapGcpDiscovery(gcpDiscoveryFixture);
// Then
expect(gcpHierarchy.orgType).toBe(ORGANIZATION_TYPE.GCP);
// The uid is the bare numeric id, not the `organizations/{id}` resource name.
expect(gcpHierarchy.organization).toEqual({
uid: "456123789012",
name: "example.com",
});
// Folders become nodes keyed by their `folders/{id}` ref, keeping the parent
// name-ref that nesting matches on.
expect(gcpHierarchy.nodes).toEqual([
{
id: "folders/1000000001",
kind: NODE_KIND.FOLDER,
name: "Engineering",
parentId: "organizations/456123789012",
},
{
id: "folders/1000000002",
kind: NODE_KIND.FOLDER,
name: "Platform",
parentId: "folders/1000000001",
},
]);
// Projects become candidates keyed by their provider uid (project_id).
expect(gcpHierarchy.candidates.map((candidate) => candidate.uid)).toEqual([
"prod-analytics",
"legacy-sandbox",
]);
// The label is the display name: a project's `name` is `projects/{number}`,
// which must never surface as one (it would also prefill the alias input).
expect(gcpHierarchy.candidates[0]).toMatchObject({
uid: "prod-analytics",
label: "Prod Analytics",
parentId: "folders/1000000001",
});
expect(
gcpHierarchy.candidates.some((candidate) =>
candidate.label.startsWith("projects/"),
),
).toBe(false);
});
it("builds a folder/project tree with org-level projects at the top level", () => {
// Given
const gcpHierarchy = mapGcpDiscovery(gcpDiscoveryFixture);
// When
const treeData = buildOrgTreeData(gcpHierarchy);
// Then — exact, not `arrayContaining`: a subset matcher also passes on the
// flattened tree a parent-ref mismatch produces.
expect(treeData.map((node) => node.id)).toEqual([
"folders/1000000001",
"legacy-sandbox",
]);
const engineering = treeData[0];
expect(engineering.children?.map((node) => node.id)).toEqual([
"folders/1000000002",
"prod-analytics",
]);
const blockedProject = treeData[1];
expect(blockedProject.disabled).toBe(true);
});
it("gives two folders sharing a display name a row each", () => {
// Given — display names are unique only among siblings, so repeats are legal.
const hierarchy = mapGcpDiscovery({
...gcpDiscoveryFixture,
folders: [
{
name: "folders/1000000001",
display_name: "qa-folder",
parent: "organizations/456123789012",
},
{
name: "folders/1000000002",
display_name: "qa-folder",
parent: "organizations/456123789012",
},
],
projects: [],
});
// When
const treeData = buildOrgTreeData(hierarchy);
// Then — two rows, one per resource name.
expect(treeData.map((node) => node.id)).toEqual([
"folders/1000000001",
"folders/1000000002",
]);
expect(treeData.every((node) => node.name === "qa-folder")).toBe(true);
});
it("treats only ready projects as selectable", () => {
// Given
const gcpHierarchy = mapGcpDiscovery(gcpDiscoveryFixture);
// When / Then
expect(getSelectableCandidateIds(gcpHierarchy)).toEqual(["prod-analytics"]);
});
});
describe("buildOrgTreeData", () => {
it("builds nested tree structure and marks blocked candidates as disabled", () => {
// Given / When
const treeData = buildOrgTreeData(hierarchy);
// Then
expect(treeData).toHaveLength(2);
@@ -106,25 +317,113 @@ describe("buildOrgTreeData", () => {
expect.arrayContaining(["ou-child", "222222222222"]),
);
const blockedAccount = parentOuNode?.children?.find(
const blockedCandidate = parentOuNode?.children?.find(
(node) => node.id === "222222222222",
);
expect(blockedAccount?.disabled).toBe(true);
expect(blockedCandidate?.disabled).toBe(true);
});
// The selection flow filters non-selectable ids out, so a click on such a row
// would be a silent no-op.
describe("containers with nothing selectable", () => {
const gcpTreeWith = (
folders: GcpDiscoveryResult["folders"],
projects: GcpDiscoveryResult["projects"],
) =>
buildOrgTreeData(
mapGcpDiscovery({ ...gcpDiscoveryFixture, folders, projects }),
);
const folder = (id: string, parent: string) => ({
name: `folders/${id}`,
display_name: `Folder ${id}`,
parent,
});
const project = (
projectId: string,
parent: string,
applyStatus: ApplyStatus,
) => ({
project_id: projectId,
name: `projects/${projectId}`,
display_name: projectId,
parent,
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required" as const,
organization_node_relation: "link_required" as const,
provider_secret_state: "will_create" as const,
apply_status: applyStatus,
blocked_reasons: applyStatus === APPLY_STATUS.BLOCKED ? ["reason"] : [],
},
});
const ORG = "organizations/456123789012";
it("disables a folder holding no projects at all", () => {
const treeData = gcpTreeWith([folder("1", ORG)], []);
expect(treeData[0].disabled).toBe(true);
});
it("disables a folder whose only projects are blocked", () => {
const treeData = gcpTreeWith(
[folder("1", ORG)],
[project("blocked-one", "folders/1", APPLY_STATUS.BLOCKED)],
);
expect(treeData[0].disabled).toBe(true);
// The blocked project keeps its own row: the folder still opens to show why.
expect(treeData[0].children?.map((node) => node.id)).toEqual([
"blocked-one",
]);
});
it("keeps a folder enabled when a nested folder holds a ready project", () => {
const treeData = gcpTreeWith(
[folder("1", ORG), folder("2", "folders/1")],
[project("ready-one", "folders/2", APPLY_STATUS.READY)],
);
expect(treeData[0].disabled).toBe(false);
expect(treeData[0].children?.[0].disabled).toBe(false);
});
it("disables every folder on a branch that dead-ends", () => {
const treeData = gcpTreeWith(
[folder("1", ORG), folder("2", "folders/1")],
[project("blocked-one", "folders/2", APPLY_STATUS.BLOCKED)],
);
expect(treeData[0].disabled).toBe(true);
expect(treeData[0].children?.[0].disabled).toBe(true);
});
it("leaves a ready candidate's own disabled flag alone", () => {
const treeData = gcpTreeWith(
[folder("1", ORG)],
[project("ready-one", "folders/1", APPLY_STATUS.READY)],
);
expect(treeData[0].children?.[0].disabled).toBe(false);
});
});
});
describe("getSelectableAccountIds", () => {
it("returns all accounts except explicitly blocked ones", () => {
const selectableIds = getSelectableAccountIds(discoveryFixture);
describe("getSelectableCandidateIds", () => {
it("returns all candidates except explicitly blocked ones", () => {
const selectableIds = getSelectableCandidateIds(hierarchy);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
it("excludes accounts with explicit non-ready status values", () => {
const discoveryWithUnexpectedStatus = {
...discoveryFixture,
it("excludes candidates with explicit non-ready status values", () => {
const hierarchyWithUnexpectedStatus = mapAwsDiscovery({
...awsDiscoveryFixture,
accounts: [
...discoveryFixture.accounts,
...awsDiscoveryFixture.accounts,
{
id: "444444444444",
arn: "arn:aws:organizations::123:account/o-example/444444444444",
@@ -138,60 +437,54 @@ describe("getSelectableAccountIds", () => {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
organization_node_relation: "link_required",
provider_secret_state: "will_create",
apply_status: "pending" as unknown as ApplyStatus,
blocked_reasons: [],
},
},
],
} satisfies DiscoveryResult;
});
const selectableIds = getSelectableAccountIds(
discoveryWithUnexpectedStatus,
const selectableIds = getSelectableCandidateIds(
hierarchyWithUnexpectedStatus,
);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
});
describe("buildAccountLookup", () => {
it("creates a lookup map for all discovered accounts", () => {
const lookup = buildAccountLookup(discoveryFixture);
describe("buildCandidateLookup", () => {
it("creates a lookup map for all discovered candidates", () => {
const lookup = buildCandidateLookup(hierarchy);
expect(lookup.get("111111111111")?.name).toBe("App Account");
expect(lookup.get("333333333333")?.name).toBe("Legacy Account");
expect(lookup.get("111111111111")?.label).toBe("App Account");
expect(lookup.get("333333333333")?.label).toBe("Legacy Account");
expect(lookup.size).toBe(3);
});
});
describe("getSelectableAccountIdsForTarget", () => {
it("scopes selection to accounts under a target OU, including nested OUs", () => {
describe("getSelectableCandidateIdsForTarget", () => {
it("scopes selection to candidates under a target node, including nested nodes", () => {
// ou-parent contains ou-child (holds 111...) and the blocked 222...
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-parent",
);
const scoped = getSelectableCandidateIdsForTarget(hierarchy, "ou-parent");
// Only the selectable descendant is returned; blocked 222... is excluded,
// and 333... (under the root, outside the OU) is not included.
// and 333... (under the root, outside the node) is not included.
expect(scoped).toEqual(["111111111111"]);
});
it("scopes selection to a leaf OU", () => {
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-child",
);
it("scopes selection to a leaf node", () => {
const scoped = getSelectableCandidateIdsForTarget(hierarchy, "ou-child");
expect(scoped).toEqual(["111111111111"]);
});
it("includes the deployment account even when it lives outside the target OU", () => {
it("includes the deployment candidate even when it lives outside the target node", () => {
// Deployment (management) account 333... sits under the root, but gets the
// role via DeployLocalRole, so it must be pre-selected alongside the OU.
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
// role via DeployLocalRole, so it must be pre-selected alongside the node.
const scoped = getSelectableCandidateIdsForTarget(
hierarchy,
"ou-child",
"333333333333",
);
@@ -199,10 +492,10 @@ describe("getSelectableAccountIdsForTarget", () => {
expect(scoped).toEqual(["111111111111", "333333333333"]);
});
it("does not include a deployment account that is not selectable", () => {
// 222... is blocked, so even as the deployment account it stays unselected.
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
it("does not include a deployment candidate that is not selectable", () => {
// 222... is blocked, so even as the deployment candidate it stays unselected.
const scoped = getSelectableCandidateIdsForTarget(
hierarchy,
"ou-child",
"222222222222",
);
@@ -210,31 +503,128 @@ describe("getSelectableAccountIdsForTarget", () => {
expect(scoped).toEqual(["111111111111"]);
});
it("returns every selectable account for a root target (whole organization)", () => {
const scoped = getSelectableAccountIdsForTarget(discoveryFixture, "r-root");
it("returns every selectable candidate for a root target (whole organization)", () => {
const scoped = getSelectableCandidateIdsForTarget(hierarchy, "r-root");
expect(scoped).toEqual(["111111111111", "333333333333"]);
});
it("falls back to all selectable accounts for an empty or unknown target", () => {
expect(getSelectableAccountIdsForTarget(discoveryFixture, "")).toEqual([
it("falls back to all selectable candidates for an empty or unknown target", () => {
expect(getSelectableCandidateIdsForTarget(hierarchy, "")).toEqual([
"111111111111",
"333333333333",
]);
expect(
getSelectableAccountIdsForTarget(discoveryFixture, "ou-does-not-exist"),
getSelectableCandidateIdsForTarget(hierarchy, "ou-does-not-exist"),
).toEqual(["111111111111", "333333333333"]);
});
});
describe("getOuIdsForSelectedAccounts", () => {
it("collects all ancestor OUs for selected accounts without duplicates", () => {
const ouIds = getOuIdsForSelectedAccounts(discoveryFixture, [
describe("getNodeIdsForSelectedCandidates", () => {
it("collects all ancestor nodes for selected candidates without duplicates", () => {
const nodeIds = getNodeIdsForSelectedCandidates(hierarchy, [
"111111111111",
"222222222222",
]);
expect(ouIds).toEqual(expect.arrayContaining(["ou-parent", "ou-child"]));
expect(ouIds.length).toBe(2);
expect(nodeIds).toEqual(expect.arrayContaining(["ou-parent", "ou-child"]));
expect(nodeIds.length).toBe(2);
});
it("terminates on a cyclic parent chain instead of hanging", () => {
// Parent ids are wire data. A cycle must not spin the ancestor walk: the
// collected ids live in a Set, so nothing about re-adding them would ever
// end the loop. Each node in the cycle is still reported once.
const cyclicHierarchy: AwsOrgHierarchy = {
orgType: ORGANIZATION_TYPE.AWS,
organization: { uid: "o-cycle", name: "Cyclic Org" },
nodes: [
{
id: "ou-a",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "A",
parentId: "ou-b",
},
{
id: "ou-b",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "B",
parentId: "ou-a",
},
{
id: "ou-self",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Self",
parentId: "ou-self",
},
],
candidates: [
{ uid: "111111111111", label: "In cycle", parentId: "ou-a" },
{ uid: "222222222222", label: "Self-parented", parentId: "ou-self" },
],
};
const nodeIds = getNodeIdsForSelectedCandidates(cyclicHierarchy, [
"111111111111",
"222222222222",
]);
expect([...nodeIds].sort()).toEqual(["ou-a", "ou-b", "ou-self"]);
});
});
describe("buildApplyPayload", () => {
it("builds the AWS payload with client-side derived organizational units", () => {
const payload = buildApplyPayload(hierarchy, ["111111111111"], {
"111111111111": "Renamed App",
});
expect(payload).toEqual({
orgType: ORGANIZATION_TYPE.AWS,
accounts: [{ id: "111111111111", alias: "Renamed App" }],
organizationalUnits: [{ id: "ou-child" }, { id: "ou-parent" }],
});
});
it("omits the alias when the candidate was not renamed", () => {
const payload = buildApplyPayload(hierarchy, ["333333333333"], {});
expect(payload).toEqual({
orgType: ORGANIZATION_TYPE.AWS,
accounts: [{ id: "333333333333" }],
// 333... hangs off the root, so no node ancestors are derived.
organizationalUnits: [],
});
});
it("builds the GCP payload with projects only (folders are server-derived)", () => {
const gcpHierarchy: GcpOrgHierarchy = {
orgType: ORGANIZATION_TYPE.GCP,
organization: { uid: "456123789012", name: "example.com" },
nodes: [
{
id: "folders/1000000001",
kind: NODE_KIND.FOLDER,
name: "Engineering",
parentId: "organizations/456123789012",
},
],
candidates: [
{
uid: "prod-analytics",
label: "Prod Analytics",
parentId: "folders/1000000001",
},
],
};
const payload = buildApplyPayload(gcpHierarchy, ["prod-analytics"], {
"prod-analytics": "Analytics",
});
expect(payload).toEqual({
orgType: ORGANIZATION_TYPE.GCP,
projects: [{ project_id: "prod-analytics", alias: "Analytics" }],
});
});
});
+275 -131
View File
@@ -1,206 +1,350 @@
import { Box, Folder, FolderTree } from "lucide-react";
import { Box, Folder } from "lucide-react";
import {
APPLY_STATUS,
DiscoveredAccount,
DiscoveryResult,
ApplyDiscoveryPayload,
AwsDiscoveryResult,
AwsOrgHierarchy,
GcpDiscoveryResult,
GcpOrgHierarchy,
NODE_KIND,
OrgCandidate,
OrgHierarchy,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import { TreeDataItem } from "@/types/tree";
/**
* Transforms flat API discovery arrays into hierarchical TreeDataItem[] for TreeView.
* Ingestion mapper: AWS discovery wire result → normalized hierarchy model.
*
* Structure: OUs -> nested OUs/Accounts (leaf nodes)
* Root nodes are used only internally for parent linking and are not rendered.
* Accounts with apply_status === "blocked" are marked disabled.
* Roots are collapsed away — OUs and accounts that sit directly under a root
* carry the root id as their `parentId`, which is absent from the node set, so
* tree rebuild treats them as top-level. Provider-specific dispatch happens here
* once, so downstream machinery is kind-driven and provider-agnostic.
*/
export function buildOrgTreeData(result: DiscoveryResult): TreeDataItem[] {
const nodeMap = new Map<string, TreeDataItem>();
export function mapAwsDiscovery(result: AwsDiscoveryResult): AwsOrgHierarchy {
const root = result.roots[0];
if (!root) throw new Error("Invalid root organization");
for (const root of result.roots) {
nodeMap.set(root.id, {
id: root.id,
return {
orgType: ORGANIZATION_TYPE.AWS,
organization: {
uid: root.id,
name: root.name,
icon: FolderTree,
children: [],
});
}
for (const ou of result.organizational_units) {
nodeMap.set(ou.id, {
},
nodes: result.organizational_units.map((ou) => ({
id: ou.id,
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: ou.name,
icon: Folder,
children: [],
});
}
parentId: ou.parent_id,
})),
candidates: result.accounts.map((account) => ({
uid: account.id,
label: account.name,
parentId: account.parent_id,
registration: account.registration,
})),
};
}
for (const account of result.accounts) {
const isBlocked =
account.registration?.apply_status === APPLY_STATUS.BLOCKED;
nodeMap.set(account.id, {
id: account.id,
name: `${account.id}${account.name}`,
icon: Box,
disabled: isBlocked,
});
}
for (const ou of result.organizational_units) {
const parent = nodeMap.get(ou.parent_id);
if (parent?.children) {
const ouNode = nodeMap.get(ou.id);
if (ouNode) {
parent.children.push(ouNode);
}
}
}
for (const account of result.accounts) {
const parent = nodeMap.get(account.parent_id);
if (!parent) {
continue;
}
if (!parent.children) {
parent.children = [];
}
const accountNode = nodeMap.get(account.id);
if (accountNode) {
parent.children.push(accountNode);
}
}
return result.roots.flatMap((root) => {
const rootNode = nodeMap.get(root.id);
return rootNode?.children ?? [];
});
/** Bare id of a canonical resource name (`folders/123` → `123`). */
function resourceId(name: string): string {
return name.split("/").at(-1) ?? name;
}
/**
* Returns IDs of accounts that can be selected.
* Accounts are selectable when registration is READY or not yet present.
* Accounts with explicit non-ready states are excluded.
* Ingestion mapper: GCP discovery wire result → normalized hierarchy model.
*
* A folder's identity is its resource `name` (`folders/{id}`), which is exactly
* what its children carry as `parent`, so nesting matches on that ref. Parents
* pointing at the organization are absent from the node set, so tree rebuild
* treats those folders/projects as top-level.
*/
export function getSelectableAccountIds(result: DiscoveryResult): string[] {
return result.accounts
.filter((account) => {
const applyStatus = account.registration?.apply_status;
export function mapGcpDiscovery(result: GcpDiscoveryResult): GcpOrgHierarchy {
return {
orgType: ORGANIZATION_TYPE.GCP,
organization: {
// Bare id: what the user typed and what the organization stores as
// `external_id`.
uid: resourceId(result.organization.name),
name: result.organization.display_name,
},
nodes: result.folders.map((folder) => ({
id: folder.name,
kind: NODE_KIND.FOLDER,
name: folder.display_name || folder.name,
parentId: folder.parent,
})),
candidates: result.projects.map((project) => ({
uid: project.project_id,
label: project.display_name || project.project_id,
parentId: project.parent,
registration: project.registration,
})),
};
}
/**
* Transforms the normalized hierarchy into hierarchical TreeDataItem[] for
* TreeView. Container nodes (OUs / folders) nest candidates (accounts /
* projects); an item is top-level when its `parentId` is not a known node
* (i.e. it hangs directly off the organization root). Kind-driven — no ID
* prefixes. Blocked candidates are marked disabled.
*/
export function buildOrgTreeData(hierarchy: OrgHierarchy): TreeDataItem[] {
const itemMap = new Map<string, TreeDataItem>();
const parentById = new Map<string, string>();
const nodeIds = new Set(hierarchy.nodes.map((node) => node.id));
for (const node of hierarchy.nodes) {
itemMap.set(node.id, {
id: node.id,
name: node.name,
icon: Folder,
kind: node.kind,
children: [],
});
parentById.set(node.id, node.parentId);
}
for (const candidate of hierarchy.candidates) {
const isBlocked =
candidate.registration?.apply_status === APPLY_STATUS.BLOCKED;
itemMap.set(candidate.uid, {
id: candidate.uid,
name: `${candidate.uid}${candidate.label}`,
icon: Box,
disabled: isBlocked,
});
parentById.set(candidate.uid, candidate.parentId);
}
const topLevel: TreeDataItem[] = [];
const link = (id: string, parentId: string) => {
const item = itemMap.get(id);
if (!item) {
return;
}
if (!nodeIds.has(parentId)) {
topLevel.push(item);
return;
}
const parent = itemMap.get(parentId);
if (parent) {
(parent.children ??= []).push(item);
}
};
// Iterating the identity map, not the source arrays, so a duplicate wire id
// collapses into one row instead of rendering N times. Insertion order keeps
// containers above their sibling leaves.
for (const id of Array.from(itemMap.keys())) {
link(id, parentById.get(id) ?? "");
}
for (const item of topLevel) {
markInertContainers(item, nodeIds);
}
return topLevel;
}
/**
* Marks containers with nothing selectable underneath as disabled, bottom-up.
* Discovery lists every folder, including project-less ones, and clicking such a
* row would otherwise do nothing at all. Returns whether the subtree holds a
* selectable candidate.
*/
function markInertContainers(
item: TreeDataItem,
nodeIds: Set<string>,
): boolean {
if (!nodeIds.has(item.id)) {
return !item.disabled;
}
// No short-circuit: every nested container has to be visited to be marked.
let hasSelectable = false;
for (const child of item.children ?? []) {
hasSelectable = markInertContainers(child, nodeIds) || hasSelectable;
}
item.disabled = !hasSelectable;
return hasSelectable;
}
/**
* Returns uids of candidates that can be selected. A candidate is selectable
* when its registration is absent or its apply_status is READY.
*/
export function getSelectableCandidateIds(hierarchy: OrgHierarchy): string[] {
return hierarchy.candidates
.filter((candidate) => {
const applyStatus = candidate.registration?.apply_status;
if (!applyStatus) {
return true;
}
return applyStatus === APPLY_STATUS.READY;
})
.map((account) => account.id);
.map((candidate) => candidate.uid);
}
/**
* Creates a lookup map from account ID to DiscoveredAccount.
* Creates a lookup map from candidate uid to the candidate.
*/
export function buildAccountLookup(
result: DiscoveryResult,
): Map<string, DiscoveredAccount> {
const map = new Map<string, DiscoveredAccount>();
for (const account of result.accounts) {
map.set(account.id, account);
export function buildCandidateLookup(
hierarchy: OrgHierarchy,
): Map<string, OrgCandidate> {
const map = new Map<string, OrgCandidate>();
for (const candidate of hierarchy.candidates) {
map.set(candidate.uid, candidate);
}
return map;
}
/**
* Returns the selectable account IDs that fall under a deployment target
* (an OU or root ID), optionally including the deployment account itself.
* Returns the selectable candidate uids that fall under a deployment target
* (an OU or root id), optionally including the deployment candidate itself.
*
* The StackSet only rolls the role out to member accounts beneath the chosen
* target, and the deployment (management or delegated administrator) account
* AWS-only by type (StackSet default-selection). The StackSet only rolls the role
* out to member accounts beneath the chosen target, and the deployment account
* gets the role via DeployLocalRole even though it usually lives outside that
* target. Pre-selecting exactly those accounts keeps the confirmation step in
* sync with what was actually deployed.
* target. Pre-selecting exactly those keeps the confirmation step in sync with
* what was deployed.
*
* Falls back to every selectable account when the target is empty or is not
* part of this discovery (e.g. a root ID), preserving the whole-organization
* default.
* Falls back to every selectable candidate when the target is empty or is not a
* known node (e.g. a root id), preserving the whole-organization default.
*/
export function getSelectableAccountIdsForTarget(
result: DiscoveryResult,
export function getSelectableCandidateIdsForTarget(
hierarchy: AwsOrgHierarchy,
targetId: string,
deploymentAccountId?: string,
deploymentCandidateId?: string,
): string[] {
const selectableAccountIds = getSelectableAccountIds(result);
const selectableCandidateIds = getSelectableCandidateIds(hierarchy);
const normalizedTarget = targetId.trim();
if (!normalizedTarget) {
return selectableAccountIds;
return selectableCandidateIds;
}
const isKnownOu = result.organizational_units.some(
(ou) => ou.id === normalizedTarget,
const isKnownNode = hierarchy.nodes.some(
(node) => node.id === normalizedTarget,
);
// Only a specific OU narrows the selection. A root ID (whole org) or an
// Only a specific node narrows the selection. A root id (whole org) or an
// unknown target keeps the whole-organization default.
if (!isKnownOu) {
return selectableAccountIds;
if (!isKnownNode) {
return selectableCandidateIds;
}
// Collect the target OU plus all of its nested descendant OUs.
// Collect the target node plus all of its nested descendant nodes.
const scopeIds = new Set<string>([normalizedTarget]);
let addedNewOu = true;
while (addedNewOu) {
addedNewOu = false;
for (const ou of result.organizational_units) {
if (!scopeIds.has(ou.id) && scopeIds.has(ou.parent_id)) {
scopeIds.add(ou.id);
addedNewOu = true;
let addedNewNode = true;
while (addedNewNode) {
addedNewNode = false;
for (const node of hierarchy.nodes) {
if (!scopeIds.has(node.id) && scopeIds.has(node.parentId)) {
scopeIds.add(node.id);
addedNewNode = true;
}
}
}
const selectableSet = new Set(selectableAccountIds);
const selectableSet = new Set(selectableCandidateIds);
const scopedIds = new Set<string>();
for (const account of result.accounts) {
if (scopeIds.has(account.parent_id) && selectableSet.has(account.id)) {
scopedIds.add(account.id);
for (const candidate of hierarchy.candidates) {
if (scopeIds.has(candidate.parentId) && selectableSet.has(candidate.uid)) {
scopedIds.add(candidate.uid);
}
}
if (deploymentAccountId && selectableSet.has(deploymentAccountId)) {
scopedIds.add(deploymentAccountId);
if (deploymentCandidateId && selectableSet.has(deploymentCandidateId)) {
scopedIds.add(deploymentCandidateId);
}
return selectableAccountIds.filter((id) => scopedIds.has(id));
return selectableCandidateIds.filter((id) => scopedIds.has(id));
}
/**
* Given selected account IDs, returns OU IDs that are ancestors of selected accounts.
* Given selected candidate uids, returns node ids that are ancestors of the
* selected candidates. AWS-only by type (client-side OU derivation for apply);
* GCP derives folder ancestors server-side.
*/
export function getOuIdsForSelectedAccounts(
result: DiscoveryResult,
selectedAccountIds: string[],
export function getNodeIdsForSelectedCandidates(
hierarchy: AwsOrgHierarchy,
selectedCandidateIds: string[],
): string[] {
const selectedSet = new Set(selectedAccountIds);
const ouIds = new Set<string>();
const allOuIds = new Set(result.organizational_units.map((ou) => ou.id));
const ouParentMap = new Map<string, string>();
const selectedSet = new Set(selectedCandidateIds);
const nodeIds = new Set<string>();
const allNodeIds = new Set(hierarchy.nodes.map((node) => node.id));
const nodeParentMap = new Map<string, string>();
for (const ou of result.organizational_units) {
ouParentMap.set(ou.id, ou.parent_id);
for (const node of hierarchy.nodes) {
nodeParentMap.set(node.id, node.parentId);
}
for (const account of result.accounts) {
if (!selectedSet.has(account.id)) {
for (const candidate of hierarchy.candidates) {
if (!selectedSet.has(candidate.uid)) {
continue;
}
let currentParentId = account.parent_id;
while (currentParentId && allOuIds.has(currentParentId)) {
ouIds.add(currentParentId);
currentParentId = ouParentMap.get(currentParentId) ?? "";
// Stops on an already-seen ancestor too: parent ids are wire data, and a
// cycle would spin forever since re-adding to a Set never terminates.
let currentParentId = candidate.parentId;
const visitedParentIds = new Set<string>();
while (
currentParentId &&
allNodeIds.has(currentParentId) &&
!visitedParentIds.has(currentParentId)
) {
visitedParentIds.add(currentParentId);
nodeIds.add(currentParentId);
currentParentId = nodeParentMap.get(currentParentId) ?? "";
}
}
return Array.from(ouIds);
return Array.from(nodeIds);
}
/**
* Builds the apply payload from the hierarchy that is being applied — the
* hierarchy's own `orgType` is the discriminant, so there is no second source of
* truth to drift from. The switch has no default: a new `OrgHierarchy` arm is a
* compile error here, and the AWS-only node derivation is unreachable from any
* other arm.
*/
export function buildApplyPayload(
hierarchy: OrgHierarchy,
selectedCandidateIds: string[],
candidateAliases: Record<string, string>,
): ApplyDiscoveryPayload {
const aliasOf = (candidateId: string) =>
candidateAliases[candidateId]
? { alias: candidateAliases[candidateId] }
: {};
switch (hierarchy.orgType) {
case ORGANIZATION_TYPE.AWS:
return {
orgType: ORGANIZATION_TYPE.AWS,
accounts: selectedCandidateIds.map((id) => ({
id,
...aliasOf(id),
})),
organizationalUnits: getNodeIdsForSelectedCandidates(
hierarchy,
selectedCandidateIds,
).map((id) => ({ id })),
};
case ORGANIZATION_TYPE.GCP:
return {
orgType: ORGANIZATION_TYPE.GCP,
projects: selectedCandidateIds.map((id) => ({
project_id: id,
...aliasOf(id),
})),
};
}
}
+273 -53
View File
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ORG_SECRET_TYPE, ORGANIZATION_TYPE } from "@/types/organizations";
const {
fetchMock,
getAuthHeadersMock,
@@ -30,11 +32,10 @@ vi.mock("@/lib/server-actions-helper", () => ({
import {
applyDiscovery,
createOrganization,
getDiscovery,
listOrganizations,
listOrganizationNodesSafe,
listOrganizationsSafe,
listOrganizationUnits,
listOrganizationUnitsSafe,
triggerDiscovery,
updateOrganizationSecret,
} from "./organizations";
@@ -48,14 +49,14 @@ describe("organizations actions", () => {
});
it("rejects invalid organization secret identifiers", async () => {
// Given
const formData = new FormData();
formData.set("organizationSecretId", "../secret-id");
formData.set("roleArn", "arn:aws:iam::123456789012:role/ProwlerOrgRole");
formData.set("externalId", "o-abc123def4");
// When
const result = await updateOrganizationSecret(formData);
const result = await updateOrganizationSecret("../secret-id", {
secretType: ORG_SECRET_TYPE.ROLE,
secret: {
role_arn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
external_id: "o-abc123def4",
},
});
// Then
expect(result).toEqual({ error: "Invalid organization secret ID" });
@@ -83,6 +84,65 @@ describe("organizations actions", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects an organization type with no onboarding flow instead of coercing it", async () => {
// Given a form asking for a type this build cannot onboard. `azure` is a
// real OrganizationType — display supports it, onboarding does not — so it
// is the exact boundary a blind cast would let through.
const formData = new FormData();
formData.set("name", "Contoso");
formData.set("externalId", "o-abc123def4");
formData.set("orgType", ORGANIZATION_TYPE.AZURE);
// When
const result = await createOrganization(formData);
// Then it never reaches the API: silently creating an AWS organization
// would onboard something the caller never asked for.
expect(result).toEqual({ error: "Invalid organization type" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects an unrecognized organization type value", async () => {
// Given a garbage value, and an empty one: present-but-empty is a different
// case from absent, and only absent may fall back to AWS.
for (const orgType of ["not-a-provider", ""]) {
const formData = new FormData();
formData.set("name", "Rogue");
formData.set("externalId", "o-abc123def4");
formData.set("orgType", orgType);
// When
const result = await createOrganization(formData);
// Then
expect(result).toEqual({ error: "Invalid organization type" });
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("defaults to AWS only when the organization type is absent", async () => {
// Given a form from before the field existed.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "org-1" } }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValue({ data: { id: "org-1" } });
const formData = new FormData();
formData.set("name", "Legacy");
formData.set("externalId", "o-abc123def4");
// When
await createOrganization(formData);
// Then the absent value — and only the absent value — means AWS.
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
expect(body.data.attributes.org_type).toBe(ORGANIZATION_TYPE.AWS);
});
it("revalidates providers only when apply discovery succeeds", async () => {
// Given
fetchMock.mockResolvedValue(
@@ -98,14 +158,12 @@ describe("organizations actions", () => {
const failedResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
{ orgType: ORGANIZATION_TYPE.AWS, accounts: [], organizationalUnits: [] },
);
const successfulResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
{ orgType: ORGANIZATION_TYPE.AWS, accounts: [], organizationalUnits: [] },
);
// Then
@@ -132,8 +190,7 @@ describe("organizations actions", () => {
const result = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
{ orgType: ORGANIZATION_TYPE.AWS, accounts: [], organizationalUnits: [] },
);
// Then
@@ -142,65 +199,228 @@ describe("organizations actions", () => {
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("lists organizations with the expected filters", async () => {
// Given
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await listOrganizations();
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://api.example.com/api/v1/organizations?filter%5Borg_type%5D=aws",
);
});
it("lists organization units from the dedicated endpoint", async () => {
// Given
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await listOrganizationUnits();
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://api.example.com/api/v1/organizational-units",
);
});
it("returns an empty organizations payload when the safe organizations request fails", async () => {
it("lists organizations across all types without a hardcoded org_type filter", async () => {
// Given
fetchMock.mockResolvedValue(
new Response("Internal Server Error", {
status: 500,
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
const result = await listOrganizationsSafe();
// Then
expect(result).toEqual({ data: [] });
expect(handleApiResponseMock).not.toHaveBeenCalled();
expect(handleApiErrorMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://api.example.com/api/v1/organizations?page%5Bnumber%5D=1&page%5Bsize%5D=100",
);
});
it("returns an empty organization units payload when the safe request fails", async () => {
it("lists organization nodes from the canonical endpoint", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
const result = await listOrganizationNodesSafe();
// Then
expect(result).toEqual({ data: [] });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://api.example.com/api/v1/organization-nodes?page%5Bnumber%5D=1&page%5Bsize%5D=100",
);
});
it("follows JSON:API pagination so hierarchy groups are never truncated", async () => {
// Given a collection spanning three pages.
const okResponse = () =>
new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
fetchMock.mockResolvedValue(okResponse());
handleApiResponseMock
.mockResolvedValueOnce({
data: [{ id: "node-1" }],
meta: { pagination: { page: 1, pages: 3, count: 3 } },
})
.mockResolvedValueOnce({
data: [{ id: "node-2" }],
meta: { pagination: { page: 2, pages: 3, count: 3 } },
})
.mockResolvedValueOnce({
data: [{ id: "node-3" }],
meta: { pagination: { page: 3, pages: 3, count: 3 } },
});
// When
const result = await listOrganizationNodesSafe();
// Then every page is requested once and the pages are merged in order.
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(
fetchMock.mock.calls.map((call) =>
new URL(call[0] as string).searchParams.get("page[number]"),
),
).toEqual(["1", "2", "3"]);
expect(result).toEqual({
data: [{ id: "node-1" }, { id: "node-2" }, { id: "node-3" }],
});
});
it("degrades instead of returning a partial hierarchy when a later page fails", async () => {
// Given a first page announcing more pages, then a failure.
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response("Internal Server Error", { status: 500 }),
);
handleApiResponseMock.mockResolvedValueOnce({
data: [{ id: "node-1" }],
meta: { pagination: { page: 1, pages: 2, count: 2 } },
});
// When
const result = await listOrganizationNodesSafe();
// Then the half-fetched hierarchy is dropped: the page shows its degraded
// notice with a flat provider list instead of partial grouping.
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).toEqual({ data: [], error: true });
});
it("flags an empty organizations payload as degraded when the safe request fails", async () => {
// Given a 5xx. The mock THROWS because that is what the real helper does
// (server-actions-helper captures to Sentry, then rethrows) — resolving
// instead would test a path production never takes.
fetchMock.mockResolvedValue(
new Response("Internal Server Error", {
status: 500,
}),
);
handleApiResponseMock.mockRejectedValue(new Error("Server error (500)"));
// When
const result = await listOrganizationsSafe();
// Then the caller still gets the degraded result, and the failure passed
// through the shared reporting point on its way there — so a 5xx behind the
// degraded-hierarchy notice is traceable instead of only a boolean.
expect(result).toEqual({ data: [], error: true });
expect(handleApiResponseMock).toHaveBeenCalledTimes(1);
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
});
it("flags an empty organization nodes payload as degraded when the safe request fails", async () => {
// Given
fetchMock.mockResolvedValue(
new Response("Internal Server Error", {
status: 500,
}),
);
handleApiResponseMock.mockRejectedValue(new Error("Server error (500)"));
// When
const result = await listOrganizationUnitsSafe();
const result = await listOrganizationNodesSafe();
// Then — same contract as the organizations read above.
expect(result).toEqual({ data: [], error: true });
expect(handleApiResponseMock).toHaveBeenCalledTimes(1);
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
});
it("reports a rejected request instead of degrading silently", async () => {
// A transport failure never reaches `handleApiResponse`, so the catch is the
// only place it can be reported.
fetchMock.mockRejectedValue(new TypeError("fetch failed"));
// When
const result = await listOrganizationNodesSafe();
// Then
expect(result).toEqual({ data: [] });
expect(result).toEqual({ data: [], error: true });
expect(handleApiResponseMock).not.toHaveBeenCalled();
expect(handleApiErrorMock).not.toHaveBeenCalled();
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
});
it("degrades instead of escaping when the session lookup throws", async () => {
// An escape here would take down the providers page's whole `Promise.all`.
getAuthHeadersMock.mockRejectedValue(new Error("No session"));
// When
const result = await listOrganizationsSafe();
// Then
expect(result).toEqual({ data: [], error: true });
expect(fetchMock).not.toHaveBeenCalled();
});
it("reports guard exhaustion instead of degrading silently past 50 pages", async () => {
// Given a collection that always announces more pages than fetched, so the
// runaway guard fires (50 × 100 = 5,000 resources). An estate that large
// degrading with only a boolean would be indistinguishable from ordinary
// fetch failures in Sentry.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValue({
data: [{ id: "node-1" }],
meta: { pagination: { page: 1, pages: 51, count: 5100 } },
});
// When
const result = await listOrganizationNodesSafe();
// Then the hierarchy degrades, and the guard firing is reported as a
// concrete error through the shared reporting point on its way there.
expect(fetchMock).toHaveBeenCalledTimes(50);
expect(result).toEqual({ data: [], error: true });
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
expect(handleApiErrorMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("pagination guard"),
}),
);
});
it("reports a 4xx page through the shared helper before degrading", async () => {
// Given a 403. The real helper reports it and RETURNS (only 5xx throws), so
// the degraded result must not depend on an exception — and the report must
// still happen, which the pre-fix early return skipped.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ errors: [{ detail: "Forbidden" }] }), {
status: 403,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValue({
error: "Forbidden",
status: 403,
});
// When
const result = await listOrganizationNodesSafe();
// Then
expect(result).toEqual({ data: [], error: true });
expect(handleApiResponseMock).toHaveBeenCalledTimes(1);
});
});
+142 -103
View File
@@ -5,8 +5,15 @@ import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
OrganizationListResponse,
OrganizationUnitListResponse,
ApplyDiscoveryPayload,
CollectionFetch,
CollectionPage,
ORGANIZATION_TYPE,
OrganizationNodeResource,
OrganizationResource,
OrganizationType,
OrgSecretPayload,
toOrgFlowType,
} from "@/types";
const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/;
@@ -41,26 +48,73 @@ function hasActionError(result: unknown): result is { error: unknown } {
);
}
async function fetchOptionalCollection<T extends { data: unknown[] }>(
const HIERARCHY_PAGE_SIZE = 100;
/** Runaway guard, not an expected path: 50 × 100 = 5000 resources. */
const HIERARCHY_MAX_PAGES = 50;
/**
* Fetches a whole collection, following JSON:API pagination — the hierarchy
* needs every organization and node to group providers, so stopping at the
* first page would silently drop groups. Same traversal the providers page
* already does for providers and provider groups (`getAllProviders`).
*
* Any incompleteness (a failed page, or the guard) resolves to the degraded
* result: callers surface "grouping unavailable" and list providers flat, which
* is truthful, where a partial hierarchy would look complete.
*/
async function fetchOptionalCollection<T>(
url: URL,
): Promise<T> {
const headers = await getAuthHeaders({ contentType: false });
): Promise<CollectionFetch<T>> {
const collected: T[] = [];
// Headers inside the try: an expired session throws, and these are awaited in
// the providers page's `Promise.all`, which has no catch.
try {
const response = await fetch(url.toString(), { headers });
const headers = await getAuthHeaders({ contentType: false });
if (!response.ok) {
return { data: [] } as unknown as T;
for (let page = 1; page <= HIERARCHY_MAX_PAGES; page += 1) {
const pageUrl = new URL(url);
pageUrl.searchParams.set("page[number]", String(page));
pageUrl.searchParams.set("page[size]", String(HIERARCHY_PAGE_SIZE));
const response = await fetch(pageUrl.toString(), { headers });
// Failures go through `handleApiResponse` too — it is the shared
// reporting point, and short-circuiting on `!response.ok` skipped it.
const body = (await handleApiResponse(response)) as CollectionPage<T>;
if (!response.ok) {
return { data: [], error: true };
}
collected.push(...(body.data ?? []));
// A missing `pages` counts as "this was the only page"; reading it as
// "maybe more" would walk to the guard on every single-page response.
if (page >= (body.meta?.pagination?.pages ?? 1)) {
return { data: collected };
}
}
return (await handleApiResponse(response)) as T;
} catch {
return { data: [] } as unknown as T;
// Guard exhaustion is not an ordinary fetch failure, and must not look
// like one in Sentry: report the concrete cause so estates above the cap
// are triageable instead of silently degraded.
handleApiError(
new Error(
`Organization hierarchy pagination guard exhausted after ${HIERARCHY_MAX_PAGES} pages (${HIERARCHY_MAX_PAGES * HIERARCHY_PAGE_SIZE} resources) for ${url.pathname}`,
),
);
return { data: [], error: true };
} catch (error) {
handleApiError(error);
return { data: [], error: true };
}
}
/**
* Creates an AWS Organization resource.
* Creates an Organization resource for the given organization type.
* POST /api/v1/organizations
*/
export const createOrganization = async (formData: FormData) => {
@@ -70,6 +124,16 @@ export const createOrganization = async (formData: FormData) => {
const name = formData.get("name") as string;
const externalId = formData.get("externalId") as string;
// Absent means AWS (the flow predates the field); unrecognized is rejected,
// not coerced to AWS.
const rawOrgType = formData.get("orgType");
const orgType =
rawOrgType === null ? ORGANIZATION_TYPE.AWS : toOrgFlowType(rawOrgType);
if (!orgType) {
return { error: "Invalid organization type" };
}
try {
const response = await fetch(url.toString(), {
method: "POST",
@@ -79,7 +143,7 @@ export const createOrganization = async (formData: FormData) => {
type: "organizations",
attributes: {
name,
org_type: "aws",
org_type: orgType,
external_id: externalId,
},
},
@@ -93,7 +157,7 @@ export const createOrganization = async (formData: FormData) => {
};
/**
* Updates an AWS Organization's name.
* Updates an Organization's name.
* PATCH /api/v1/organizations/{id}
*/
export const updateOrganizationName = async (
@@ -146,14 +210,17 @@ export const updateOrganizationName = async (
};
/**
* Lists AWS Organizations filtered by external ID.
* GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]=aws
* Lists organizations filtered by external ID and organization type.
* GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]={orgType}
*/
export const listOrganizationsByExternalId = async (externalId: string) => {
export const listOrganizationsByExternalId = async (
externalId: string,
orgType: OrganizationType = ORGANIZATION_TYPE.AWS,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[external_id]", externalId);
url.searchParams.set("filter[org_type]", "aws");
url.searchParams.set("filter[org_type]", orgType);
try {
const response = await fetch(url.toString(), { headers });
@@ -164,67 +231,39 @@ export const listOrganizationsByExternalId = async (externalId: string) => {
};
/**
* Lists AWS organizations available for the current tenant.
* GET /api/v1/organizations?filter[org_type]=aws
* Lists every organization for the current tenant, across organization types.
* GET /api/v1/organizations
*/
export const listOrganizations = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[org_type]", "aws");
try {
const response = await fetch(url.toString(), { headers });
return await handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const listOrganizationsSafe =
async (): Promise<OrganizationListResponse> => {
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[org_type]", "aws");
url.searchParams.set("page[size]", "100");
return fetchOptionalCollection<OrganizationListResponse>(url);
};
export const listOrganizationsSafe = async (): Promise<
CollectionFetch<OrganizationResource>
> =>
fetchOptionalCollection<OrganizationResource>(
new URL(`${apiBaseUrl}/organizations`),
);
/**
* Lists organization units available for the current tenant.
* GET /api/v1/organizational-units
* Lists every organization node. A large AWS organization can hold hundreds of
* OUs, so this is the collection most likely to span pages.
* GET /api/v1/organization-nodes
*/
export const listOrganizationUnits = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organizational-units`);
try {
const response = await fetch(url.toString(), { headers });
return await handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const listOrganizationUnitsSafe =
async (): Promise<OrganizationUnitListResponse> => {
const url = new URL(`${apiBaseUrl}/organizational-units`);
url.searchParams.set("page[size]", "100");
return fetchOptionalCollection<OrganizationUnitListResponse>(url);
};
export const listOrganizationNodesSafe = async (): Promise<
CollectionFetch<OrganizationNodeResource>
> =>
fetchOptionalCollection<OrganizationNodeResource>(
new URL(`${apiBaseUrl}/organization-nodes`),
);
/**
* Creates an organization secret (role-based credentials).
* Creates an organization secret for the given secret payload.
* POST /api/v1/organization-secrets
*/
export const createOrganizationSecret = async (formData: FormData) => {
export const createOrganizationSecret = async (
organizationId: string,
payload: OrgSecretPayload,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/organization-secrets`);
const organizationId = formData.get("organizationId") as string;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
try {
const response = await fetch(url.toString(), {
method: "POST",
@@ -233,11 +272,8 @@ export const createOrganizationSecret = async (formData: FormData) => {
data: {
type: "organization-secrets",
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
secret_type: payload.secretType,
secret: payload.secret,
},
relationships: {
organization: {
@@ -258,16 +294,14 @@ export const createOrganizationSecret = async (formData: FormData) => {
};
/**
* Updates an organization secret (role-based credentials).
* Updates an organization secret with the given secret payload.
* PATCH /api/v1/organization-secrets/{id}
*/
export const updateOrganizationSecret = async (formData: FormData) => {
export const updateOrganizationSecret = async (
organizationSecretId: string,
payload: OrgSecretPayload,
) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationSecretId = formData.get("organizationSecretId") as
| string
| null;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
const organizationSecretIdValidation = validatePathIdentifier(
organizationSecretId,
@@ -291,11 +325,8 @@ export const updateOrganizationSecret = async (formData: FormData) => {
type: "organization-secrets",
id: organizationSecretIdValidation.value,
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
secret_type: payload.secretType,
secret: payload.secret,
},
},
}),
@@ -327,7 +358,7 @@ export const listOrganizationSecretsByOrganizationId = async (
};
/**
* Deletes an AWS Organization resource.
* Deletes an Organization resource.
* DELETE /api/v1/organizations/{id}
*/
export const deleteOrganization = async (organizationId: string) => {
@@ -359,25 +390,23 @@ export const deleteOrganization = async (organizationId: string) => {
};
/**
* Deletes an organizational unit.
* DELETE /api/v1/organizational-units/{id}
* Deletes an organization node.
* DELETE /api/v1/organization-nodes/{id}
*/
export const deleteOrganizationalUnit = async (
organizationalUnitId: string,
) => {
export const deleteOrganizationNode = async (organizationNodeId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const idValidation = validatePathIdentifier(
organizationalUnitId,
"Organizational unit ID is required",
"Invalid organizational unit ID",
organizationNodeId,
"Organization node ID is required",
"Invalid organization node ID",
);
if ("error" in idValidation) {
return idValidation;
}
const url = new URL(
`${apiBaseUrl}/organizational-units/${encodeURIComponent(idValidation.value)}`,
`${apiBaseUrl}/organization-nodes/${encodeURIComponent(idValidation.value)}`,
);
try {
@@ -393,7 +422,7 @@ export const deleteOrganizationalUnit = async (
};
/**
* Triggers an async discovery of the AWS Organization.
* Triggers an async discovery of the Organization.
* POST /api/v1/organizations/{id}/discover
*/
export const triggerDiscovery = async (organizationId: string) => {
@@ -461,14 +490,16 @@ export const getDiscovery = async (
};
/**
* Applies discovery results — creates providers, links to org/OUs, auto-generates secrets.
* Applies discovery results — creates providers, links to org/nodes,
* auto-generates secrets. The payload is discriminated by organization type:
* AWS sends `accounts` + client-side-derived `organizational_units`; GCP sends
* `projects` only (folder ancestors are derived server-side).
* POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply
*/
export const applyDiscovery = async (
organizationId: string,
discoveryId: string,
accounts: Array<{ id: string; alias?: string }>,
organizationalUnits: Array<{ id: string }>,
payload: ApplyDiscoveryPayload,
) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationIdValidation = validatePathIdentifier(
@@ -490,6 +521,17 @@ export const applyDiscovery = async (
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discoveries/${encodeURIComponent(discoveryIdValidation.value)}/apply`,
);
// No `include`: the apply view rejects the parameter outright and fails the
// whole request. The created providers' uids are read afterwards instead, with
// `getProviderUidsByIds`.
const attributes =
payload.orgType === ORGANIZATION_TYPE.AWS
? {
accounts: payload.accounts,
organizational_units: payload.organizationalUnits,
}
: { projects: payload.projects };
try {
const response = await fetch(url.toString(), {
@@ -498,10 +540,7 @@ export const applyDiscovery = async (
body: JSON.stringify({
data: {
type: "organization-discoveries",
attributes: {
accounts,
organizational_units: organizationalUnits,
},
attributes,
},
}),
});
+103 -1
View File
@@ -6,12 +6,14 @@ const {
getFormValueMock,
handleApiErrorMock,
handleApiResponseMock,
waitMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
getFormValueMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
waitMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
@@ -26,7 +28,7 @@ vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getFormValue: getFormValueMock,
wait: vi.fn(),
wait: waitMock,
}));
vi.mock("@/lib/provider-credentials/build-credentials", () => ({
@@ -49,6 +51,7 @@ import {
addCredentialsProvider,
addProvider,
checkConnectionProvider,
startProviderConnectionChecks,
updateCredentialsProvider,
} from "./providers";
@@ -133,3 +136,102 @@ describe("providers actions", () => {
);
});
});
describe("startProviderConnectionChecks", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
fetchMock.mockResolvedValue(new Response(null, { status: 202 }));
});
it("dispatches one check per provider and returns the task each is tested by", async () => {
// Given
handleApiResponseMock.mockImplementation(async () => ({
data: { id: `task-${handleApiResponseMock.mock.calls.length}` },
}));
// When
const outcomes = await startProviderConnectionChecks([
"provider-1",
"provider-2",
]);
// Then
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(Object.keys(outcomes).sort()).toEqual(["provider-1", "provider-2"]);
expect(Object.values(outcomes).map((outcome) => outcome.taskId)).toEqual(
expect.arrayContaining(["task-1", "task-2"]),
);
});
it("skips the single-provider padding and the per-provider revalidation", async () => {
// Given
handleApiResponseMock.mockResolvedValue({ data: { id: "task-1" } });
// When
await startProviderConnectionChecks(["provider-1", "provider-2"]);
// Then
expect(waitMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).toHaveBeenCalledWith(
expect.any(Response),
undefined,
);
});
it("keeps a failed dispatch to its own provider, error payload included", async () => {
// Given
const failure = { error: "Provider not found.", status: 404 };
handleApiResponseMock
.mockResolvedValueOnce({ data: { id: "task-1" } })
.mockResolvedValueOnce(failure);
// When
const outcomes = await startProviderConnectionChecks([
"provider-1",
"provider-2",
]);
// Then
expect(outcomes["provider-1"]).toEqual({ taskId: "task-1" });
expect(outcomes["provider-2"]).toEqual({ error: failure });
});
it("keeps a thrown request to its own provider", async () => {
// Given
handleApiResponseMock.mockResolvedValue({ data: { id: "task-1" } });
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 202 }))
.mockRejectedValueOnce(new Error("socket hang up"));
// When
const outcomes = await startProviderConnectionChecks([
"provider-1",
"provider-2",
]);
// Then
expect(outcomes["provider-1"]).toEqual({ taskId: "task-1" });
expect(outcomes["provider-2"]).toEqual({
error: { error: "Unexpected error" },
});
});
it("ignores blank and duplicated ids", async () => {
// Given
handleApiResponseMock.mockResolvedValue({ data: { id: "task-1" } });
// When
const outcomes = await startProviderConnectionChecks([
"provider-1",
"provider-1",
"",
]);
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(Object.keys(outcomes)).toEqual(["provider-1"]);
});
});
+109 -4
View File
@@ -3,7 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib";
import { apiBaseUrl, getAuthHeaders, getFormValue } from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { appendSanitizedProviderInFilters } from "@/lib/provider-filters";
@@ -146,6 +147,56 @@ export const getProvider = async (formData: FormData) => {
}
};
/** Server max for `page[size]`, which also bounds the id batch size. */
const PROVIDERS_PAGE_MAX = 100;
/**
* Uids of the given providers, keyed by provider id. A provider's `uid` is the
* candidate it was created for (AWS account id / GCP project id), so this is what
* matches an apply's created providers back to the selection. Batched with
* `filter[id__in]` rather than one `GET /providers/{id}` per id.
*/
export const getProviderUidsByIds = async (
providerIds: string[],
): Promise<Record<string, string>> => {
const uniqueIds = Array.from(new Set(providerIds.filter(Boolean)));
if (uniqueIds.length === 0) {
return {};
}
const headers = await getAuthHeaders({ contentType: false });
const batches: string[][] = [];
for (let start = 0; start < uniqueIds.length; start += PROVIDERS_PAGE_MAX) {
batches.push(uniqueIds.slice(start, start + PROVIDERS_PAGE_MAX));
}
const uidById: Record<string, string> = {};
for (const batch of batches) {
const url = new URL(`${apiBaseUrl}/providers`);
url.searchParams.set("filter[id__in]", batch.join(","));
url.searchParams.set("page[size]", String(PROVIDERS_PAGE_MAX));
try {
const response = await fetch(url.toString(), { headers });
const result = (await handleApiResponse(response)) as
| ProvidersApiResponse
| undefined;
for (const provider of result?.data ?? []) {
const uid = provider?.attributes?.uid;
if (typeof provider?.id === "string" && typeof uid === "string") {
uidById[provider.id] = uid;
}
}
} catch {
// A failed batch leaves its providers unmapped rather than failing the rest.
}
}
return uidById;
};
export const updateProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID);
@@ -299,21 +350,75 @@ export const updateCredentialsProvider = async (
}
};
export const checkConnectionProvider = async (formData: FormData) => {
export const checkConnectionProvider = async (
formData: FormData,
{ revalidate = true }: { revalidate?: boolean } = {},
) => {
const headers = await getAuthHeaders({ contentType: false });
const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID);
const url = new URL(`${apiBaseUrl}/providers/${providerId}/connection`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
await wait(2000);
return handleApiResponse(response, "/providers");
// Batches opt out: revalidating here would re-render the providers page
// once per provider.
return handleApiResponse(response, revalidate ? "/providers" : undefined);
} catch (error) {
return handleApiError(error);
}
};
/** Connection checks in flight at once. */
const CONNECTION_CHECK_CONCURRENCY_LIMIT = 10;
/**
* Dispatches a connection check per provider, returning the task testing each
* one keyed by provider id. A failed dispatch is reported under `error` and
* never cancels the rest of the batch.
*
* The fan-out belongs here, not in the caller: client-invoked server actions run
* one at a time through Next's action queue, so a client-side loop is serialized
* whatever concurrency it declares.
*/
export const startProviderConnectionChecks = async (
providerIds: string[],
): Promise<Record<string, { taskId?: string; error?: unknown }>> => {
const uniqueIds = Array.from(new Set(providerIds.filter(Boolean)));
const outcomes: Record<string, { taskId?: string; error?: unknown }> = {};
await runWithConcurrencyLimit(
uniqueIds,
CONNECTION_CHECK_CONCURRENCY_LIMIT,
async (providerId) => {
const formData = new FormData();
formData.set(ProviderCredentialFields.PROVIDER_ID, providerId);
try {
const result = await checkConnectionProvider(formData, {
revalidate: false,
});
if (result?.error || result?.errors?.length) {
outcomes[providerId] = { error: result };
return;
}
outcomes[providerId] = { taskId: result?.data?.id };
} catch (error) {
outcomes[providerId] = { error: handleApiError(error) };
}
},
);
return outcomes;
};
/** Called once after a batch of checks, which revalidate nothing themselves. */
export const revalidateProviders = async () => {
revalidatePath("/providers");
};
export const deleteCredentials = async (secretId: string) => {
const headers = await getAuthHeaders({ contentType: false });
+5 -14
View File
@@ -73,13 +73,7 @@ describe("schedule write actions revalidate only on success", () => {
it("posts the JSON:API bulk schedule payload", async () => {
handleApiResponseMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: [PROVIDER_ID, SECOND_PROVIDER_ID],
failed: [],
},
},
data: { updated: [PROVIDER_ID, SECOND_PROVIDER_ID], failed: [] },
});
await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload);
@@ -146,11 +140,8 @@ describe("schedule write actions revalidate only on success", () => {
it("revalidates /scans and /providers after a partial bulk success", async () => {
handleApiResponseMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: [PROVIDER_ID],
failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }],
},
updated: [PROVIDER_ID],
failed: [{ id: SECOND_PROVIDER_ID, error: "Denied" }],
},
});
@@ -159,8 +150,8 @@ describe("schedule write actions revalidate only on success", () => {
payload,
);
expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]);
expect(result.data?.attributes?.failed).toHaveLength(1);
expect(result.data?.updated).toEqual([PROVIDER_ID]);
expect(result.data?.failed).toHaveLength(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { getTasksByIds } from "./tasks";
describe("getTasksByIds", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("reads every task in one call, keyed by id, on a single auth read", async () => {
// Given
handleApiResponseMock.mockImplementation(async () => ({
data: { attributes: { state: "executing" } },
}));
// When
const snapshots = await getTasksByIds(["task-a", "task-b"]);
// Then — one request per task (`TaskFilter` has no id filter), overlapping.
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAuthHeadersMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"https://api.example.com/api/v1/tasks/task-a",
"https://api.example.com/api/v1/tasks/task-b",
]);
expect(Object.keys(snapshots)).toEqual(["task-a", "task-b"]);
});
it("keeps a failed read to its own task", async () => {
// Given
handleApiResponseMock.mockResolvedValue({
data: { attributes: { state: "completed" } },
});
fetchMock
.mockRejectedValueOnce(new Error("socket hang up"))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
// When
const snapshots = await getTasksByIds(["task-a", "task-b"]);
// Then
expect(snapshots["task-a"]).toEqual({ error: "Unexpected error" });
expect(snapshots["task-b"]).toEqual({
data: { attributes: { state: "completed" } },
});
});
it("does not reach the API for an empty batch", async () => {
// When
const snapshots = await getTasksByIds([]);
// Then
expect(snapshots).toEqual({});
expect(getAuthHeadersMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+42
View File
@@ -1,6 +1,7 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
export const getTask = async (taskId: string) => {
@@ -18,3 +19,44 @@ export const getTask = async (taskId: string) => {
return handleApiError(error);
}
};
/** Task reads in flight at once. */
const TASK_READ_CONCURRENCY_LIMIT = 10;
/**
* Reads several tasks in one call, keyed by task id, each entry shaped like
* {@link getTask}'s.
*
* Client-invoked server actions run one at a time through Next's action queue,
* so polling N tasks with `getTask` costs N sequential round trips per round.
* The reads are still one request per task — `TaskFilter` exposes no id filter —
* but they overlap here instead of queueing on the client.
*/
export const getTasksByIds = async (
taskIds: string[],
): Promise<Record<string, unknown>> => {
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
if (uniqueIds.length === 0) {
return {};
}
const headers = await getAuthHeaders({ contentType: false });
const snapshots: Record<string, unknown> = {};
await runWithConcurrencyLimit(
uniqueIds,
TASK_READ_CONCURRENCY_LIMIT,
async (taskId) => {
const url = new URL(`${apiBaseUrl}/tasks/${taskId}`);
try {
const response = await fetch(url.toString(), { headers });
snapshots[taskId] = await handleApiResponse(response);
} catch (error) {
snapshots[taskId] = handleApiError(error);
}
},
);
return snapshots;
};
@@ -5,12 +5,13 @@
*/
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import { BrowserHarness } from "@/__tests__/browser-harness";
import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
private static readonly NODE_SEL = ".react-flow__node";
private static readonly EDGE_SEL = ".react-flow__edge";
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
@@ -38,16 +39,6 @@ export class AttackPathPageHarness {
);
}
readonly user = userEvent;
constructor(readonly fixture: PageFixture) {}
// --- Container ---
get container(): HTMLElement {
return document.body;
}
// --- Collections ---
get nodes(): HTMLElement[] {
@@ -167,10 +158,6 @@ export class AttackPathPageHarness {
// --- Handles ---
private q(selector: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(selector);
}
get toolbar() {
const exportButton =
this.q('button[aria-label="Export graph"]') ??
@@ -200,11 +187,6 @@ export class AttackPathPageHarness {
return alert.textContent ?? "";
}
/** True when the rendered page contains text matching `pattern`. */
containsText(pattern: RegExp): boolean {
return pattern.test(this.container.textContent ?? "");
}
get minimap(): HTMLElement | null {
return this.q(AttackPathPageHarness.MINIMAP_SEL);
}
@@ -227,14 +209,35 @@ export class AttackPathPageHarness {
);
}
getInputByName(name: string): HTMLInputElement | null {
return this.container.querySelector<HTMLInputElement>(
`input[name="${name}"]`,
);
/** Whether the query-parameters panel is shown. */
showsQueryParameters(): boolean {
return this.containsText(/Query Parameters/i);
}
/** Whether a query-parameter field label is shown (case-insensitive). */
showsParameterLabel(label: string): boolean {
return (this.container.textContent ?? "")
.toLowerCase()
.includes(label.toLowerCase());
}
/** Whether a query-parameter input is rendered, addressed by its name. */
hasParameterInput(name: string): boolean {
return this.inputByName(name) !== null;
}
/** Whether a graph node label is rendered on the page. */
showsNodeLabel(label: string): boolean {
return this.container.textContent?.includes(label) ?? false;
}
/** Wait until the query-builder card is present. */
async waitForQueryBuilderCard(timeoutMs = 10000): Promise<HTMLElement> {
return this.waitFor(() => this.queryBuilderCard, timeoutMs);
}
async fillInput(name: string, value: string): Promise<void> {
const input = this.getInputByName(name);
const input = this.inputByName(name);
if (!input) throw new Error(`fillInput: input "${name}" not found`);
await this.user.fill(input, value);
}
@@ -249,6 +252,22 @@ export class AttackPathPageHarness {
return this.viewport?.style.transform ?? "";
}
/** Wait until the React Flow viewport transform changes from `previous`. */
async waitForViewportChange(
previous: string,
timeoutMs = 2000,
): Promise<void> {
await this.waitFor(() => this.viewportTransform !== previous, timeoutMs);
}
/** Wait until exactly `count` edges are highlighted. */
async waitForHighlightedEdges(
count: number,
timeoutMs = 2000,
): Promise<void> {
await this.waitFor(() => this.highlightedEdges.length === count, timeoutMs);
}
/**
* `stroke-width` of the minimap mask SVG. The mask cuts out the area
* currently in view; the cut-out's border is what indicates the viewport
@@ -328,39 +347,8 @@ export class AttackPathPageHarness {
);
}
/** Wait until the predicate returns truthy and return that value. */
async waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs = 3000,
): Promise<T> {
return vi.waitFor(
() => {
const v = fn();
if (!v) throw new Error("waitFor predicate not yet truthy");
return v;
},
{ timeout: timeoutMs, interval: 16 },
) as Promise<T>;
}
async waitForTransition(ms = 350): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
// --- Action methods ---
private async clickElement(
element: HTMLElement,
options?: { fallbackToDomClick?: boolean },
): Promise<void> {
try {
await this.user.click(element);
} catch (error) {
if (!options?.fallbackToDomClick) throw error;
element.click();
}
}
private async clickGraphElement(element: HTMLElement): Promise<void> {
await this.closeFindingDrawerIfOpen();
await this.user.click(element);
@@ -111,11 +111,11 @@ describe("running a query", () => {
await graph.selectQuery();
expect(graph.containsText(/Query Parameters/i)).toBe(true);
expect(graph.containsText(/Tag key/i)).toBe(true);
expect(graph.getInputByName("tag_key")).toBeTruthy();
expect(graph.containsText(/Tag value/i)).toBe(true);
expect(graph.getInputByName("tag_value")).toBeTruthy();
expect(graph.showsQueryParameters()).toBe(true);
expect(graph.showsParameterLabel("Tag key")).toBe(true);
expect(graph.hasParameterInput("tag_key")).toBe(true);
expect(graph.showsParameterLabel("Tag value")).toBe(true);
expect(graph.hasParameterInput("tag_value")).toBe(true);
});
test("changing the form keeps Lighthouse bound to the query that produced the graph", async ({
@@ -345,7 +345,7 @@ describe("running a query", () => {
await graph.waitForGraphStable(5);
expect(graph.nodes.length).toBe(7);
expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true);
expect(graph.showsNodeLabel("🔒-secure-bucket-日本語")).toBe(true);
});
});
@@ -413,19 +413,13 @@ describe("exploring the graph", () => {
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitFor(
() => graph.viewportTransform !== initialViewport,
2000,
);
await graph.waitForViewportChange(initialViewport);
const contextualViewport = graph.viewportTransform;
await graph.fit();
await graph.waitFor(
() => graph.viewportTransform !== contextualViewport,
2000,
);
await graph.waitForViewportChange(contextualViewport);
});
test("clicking an expanded resource re-fits the remaining visible graph", async ({
mountWith,
@@ -436,6 +430,9 @@ describe("exploring the graph", () => {
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
// This flow asserts the re-fit animation moves the viewport off the
// mid-transition value captured next; there is no queryable settled state
// to wait on, so a fixed settle is the honest tool here.
await graph.waitForTransition();
const expandedViewport = graph.viewportTransform;
@@ -443,10 +440,7 @@ describe("exploring the graph", () => {
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBe(0);
await graph.waitFor(
() => graph.viewportTransform !== expandedViewport,
2000,
);
await graph.waitForViewportChange(expandedViewport);
});
test("returning from a finding keeps the expanded findings context fitted", async ({
@@ -458,7 +452,6 @@ describe("exploring the graph", () => {
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
@@ -466,7 +459,6 @@ describe("exploring the graph", () => {
await graph.exitFilteredView();
expect(graph.isInFilteredView).toBe(false);
await graph.waitForTransition();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.viewportTransform).toBeTruthy();
@@ -530,14 +522,14 @@ describe("exploring the graph", () => {
.sort();
await graph.hoverFirstResourceNode();
await graph.waitForTransition(120);
await graph.waitForHighlightedEdges(expectedHighlightedIds.length);
expect(
graph.highlightedEdges.map((edge) => edge.dataset.id ?? "").sort(),
).toEqual(expectedHighlightedIds);
await graph.unhoverNodes();
await graph.waitForTransition(120);
await graph.waitForHighlightedEdges(0);
expect(graph.highlightedEdges.length).toBe(0);
});
@@ -610,8 +602,9 @@ describe("auto-fitting the viewport", () => {
// sit entirely outside the current frame. The expand auto-fit should then
// recover the user instead of leaving them hunting off-screen.
for (let i = 0; i < 5; i++) {
const zoomedFrom = graph.viewportTransform;
await graph.zoomIn();
await graph.waitForTransition(80);
await graph.waitForViewportChange(zoomedFrom);
}
// Hidden findings are not measured by the initial declarative fit, so
// their positions can sit outside the framed viewport. Expanding the
@@ -621,7 +614,7 @@ describe("auto-fitting the viewport", () => {
expect(before).toBeTruthy();
await graph.expandAllFindings();
await graph.waitForTransition();
await graph.waitForViewportChange(before);
expect(graph.viewportTransform).not.toBe(before);
});
@@ -639,7 +632,7 @@ describe("auto-fitting the viewport", () => {
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
await graph.waitForViewportChange(beforeFilter);
expect(graph.viewportTransform).not.toBe(beforeFilter);
});
@@ -651,14 +644,16 @@ describe("auto-fitting the viewport", () => {
await graph.executeQuery();
await graph.waitForGraphStable(3);
await graph.expandAllFindings();
const beforeFilter = graph.viewportTransform;
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
await graph.waitForViewportChange(beforeFilter);
const filterT = graph.viewportTransform;
await graph.exitFilteredView();
await graph.waitForGraphStable(3);
await graph.waitForTransition();
await graph.waitForViewportChange(filterT);
expect(graph.viewportTransform).not.toBe(filterT);
});
+1 -53
View File
@@ -1,7 +1,5 @@
import { Suspense } from "react";
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -9,15 +7,11 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { FilterTransitionWrapper } from "@/contexts";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import {
SCAN_CONFIGURATION_LIST_STATUS,
type ScanConfigurationListState,
} from "@/types/scan-configurations";
import { ProviderGroupsContent } from "./provider-groups-content";
import { ProviderPageTabs } from "./provider-page-tabs";
import { getProviderTab } from "./provider-page-tabs.shared";
import { loadProvidersAccountsViewData } from "./providers-page.utils";
import { ProvidersTabContent } from "./providers-tab-content";
export default async function Providers({
searchParams,
@@ -108,49 +102,3 @@ const ProviderGroupsFallback = () => {
</div>
);
};
const loadScanConfigs = async (
isCloud: boolean,
): Promise<ScanConfigurationListState> => {
if (!isCloud) {
return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] };
}
try {
return {
status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
data: await listScanConfigurations(),
};
} catch (error) {
console.error("Error loading provider scan configurations:", error);
return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] };
}
};
const ProvidersTabContent = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
searchParams,
isCloud: isCloudEnvironment,
}),
loadScanConfigs(isCloudEnvironment),
]);
return (
<ProvidersAccountsView
isCloud={isCloudEnvironment}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
metadata={providersView.metadata}
rows={providersView.rows}
scanConfigs={scanConfigsState.data}
scanConfigStatus={scanConfigsState.status}
/>
);
};
@@ -0,0 +1,481 @@
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 initial scans only for the projects whose schedule was saved", 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.scanLaunchCount).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.scanLaunchCount).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.scanLaunchCount).toBe(GCP_CREATED_PROVIDER_IDS.length);
}, 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);
});
@@ -0,0 +1,965 @@
/**
* 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;
}
export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
get applyCallCount(): number {
return this.countRequests("POST", "/apply");
}
/**
* 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");
}
/**
* 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 scans were launched (one per provider whose schedule saved). */
get scanLaunchCount(): number {
return this.countRequests("POST", "/scans");
}
// --- 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 the AWS/GCP 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/);
await this.chooseMethod(/Add Multiple Accounts With AWS Organizations/);
await this.waitForText(/Organization Details/);
}
/** 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/);
}
/** 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: 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.
*/
hasApplyOverwriteWarning(
projectCount: number,
names: string[] = [],
): boolean {
const states = this.containsText(
new RegExp(
`overwrite the credentials of ${projectCount} already-onboarded project`,
),
);
return states && names.every((name) => this.containsText(new RegExp(name)));
}
/** 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);
}
/** 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.
private selectedCountText(): string {
return (
this.container.textContent?.match(
/\d+ of \d+ (?:accounts|projects) 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 identifies itself by its uid: a GCP folder ref or an AWS OU id. */
private static readonly CONTAINER_UID = /folders\/\d+|ou-[\w-]+/;
private get containerRows(): HTMLElement[] {
return this.treeItems.filter((item) =>
ProvidersPageHarness.CONTAINER_UID.test(item.textContent ?? ""),
);
}
/**
* 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) =>
item.textContent?.match(ProvidersPageHarness.CONTAINER_UID) ?? [],
);
}
/** 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),
);
return (
row?.querySelector('[role="img"]')?.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");
}
/**
* 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 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));
}
/** 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/);
}
/** 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(
/If left blank, Prowler will use the name stored in AWS/,
);
}
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*$/);
}
}
@@ -0,0 +1,134 @@
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);
});
@@ -0,0 +1,226 @@
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
awsHierarchyFixture,
awsOnboardingFixture,
type OrgFixture,
} from "@/__tests__/msw/handlers/organizations.fixtures";
import { ProvidersPageHarness } from "./providers-page.harness";
const AWS_ORG_ID = "o-aws0abcdef";
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";
/** Providers `awsOnboardingFixture`'s apply creates (one per ready account). */
const CREATED_PROVIDER_COUNT = 2;
/** A world where one of the two ready accounts fails its connection test. */
const partialConnectionFixture = (): OrgFixture =>
awsOnboardingFixture({
connectionByUid: {
"111111111111": { connected: true },
"222222222222": { connected: false, error: "Access denied" },
},
});
/** Drive a fresh AWS org onboarding up to the populated selection tree. */
async function onboardToSelection(
harness: ProvidersPageHarness,
): Promise<void> {
await harness.mount();
await harness.chooseAwsOrganizations();
await harness.fillAwsOrgDetails(AWS_ORG_ID, "My AWS Org");
await harness.submitOrganizationDetails();
await harness.fillAwsAccess({ ouId: "r-aws0", roleArn: AWS_ROLE_ARN });
await harness.authenticate();
await harness.waitForSelectionTree();
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.scanLaunchCount).toBe(CREATED_PROVIDER_COUNT);
}, 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);
});
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 });
// 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");
// 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);
// 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);
// 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);
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);
// The edit-name affordance is an inline modal (not the wizard) today.
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);
// 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);
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);
// Cascade confirmation dialog, stating the affected provider count.
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);
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 });
// 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);
});
@@ -7,7 +7,7 @@ const providersActionsMock = vi.hoisted(() => ({
const organizationsActionsMock = vi.hoisted(() => ({
listOrganizationsSafe: vi.fn(),
listOrganizationUnitsSafe: vi.fn(),
listOrganizationNodesSafe: vi.fn(),
}));
const scansActionsMock = vi.hoisted(() => ({
@@ -32,8 +32,10 @@ vi.mock("@/actions/schedules", () => schedulesActionsMock);
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
import { SearchParamsProps } from "@/types";
import { NODE_KIND } from "@/types/organizations";
import { ProvidersApiResponse } from "@/types/providers";
import {
HIERARCHY_STATUS,
isProvidersOrganizationRow,
ProvidersProviderRow,
} from "@/types/providers-table";
@@ -214,7 +216,7 @@ describe("buildProvidersTableRows", () => {
const rows = buildProvidersTableRows({
providers,
organizations: [],
organizationUnits: [],
organizationNodes: [],
isCloud: false,
});
@@ -236,10 +238,10 @@ describe("buildProvidersTableRows", () => {
? { type: "organizations", id: "org-1" }
: null,
},
organization_unit: {
organization_node: {
data:
provider.id === "provider-1"
? { type: "organizational-units", id: "ou-1" }
? { type: "organization-nodes", id: "ou-1" }
: null,
},
},
@@ -263,11 +265,12 @@ describe("buildProvidersTableRows", () => {
relationships: {},
},
],
organizationUnits: [
organizationNodes: [
{
id: "ou-1",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Security OU",
external_id: "ou-security",
parent_external_id: "r-root",
@@ -306,8 +309,8 @@ describe("buildProvidersTableRows", () => {
organization: {
data: { type: "organizations", id: "org-1" },
},
organization_unit: {
data: { type: "organizational-units", id: "ou-grandchild" },
organization_node: {
data: { type: "organization-nodes", id: "ou-grandchild" },
},
},
}),
@@ -330,11 +333,12 @@ describe("buildProvidersTableRows", () => {
relationships: {},
},
],
organizationUnits: [
organizationNodes: [
{
id: "ou-root",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Production",
external_id: "ou-prod",
parent_external_id: "r-root",
@@ -348,8 +352,9 @@ describe("buildProvidersTableRows", () => {
},
{
id: "ou-child",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "EMEA",
external_id: "ou-emea",
parent_external_id: "ou-prod",
@@ -363,8 +368,9 @@ describe("buildProvidersTableRows", () => {
},
{
id: "ou-grandchild",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Security",
external_id: "ou-security",
parent_external_id: "ou-emea",
@@ -421,11 +427,12 @@ describe("buildProvidersTableRows", () => {
relationships: {},
},
],
organizationUnits: [
organizationNodes: [
{
id: "ou-parent",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Workloads",
external_id: "ou-workloads",
parent_external_id: null,
@@ -442,8 +449,9 @@ describe("buildProvidersTableRows", () => {
},
{
id: "ou-child",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Team A",
external_id: "ou-team-a",
parent_external_id: null,
@@ -454,7 +462,7 @@ describe("buildProvidersTableRows", () => {
data: { type: "organizations", id: "org-1" },
},
parent: {
data: { type: "organizational-units", id: "ou-parent" },
data: { type: "organization-nodes", id: "ou-parent" },
},
providers: {
data: [{ type: "providers", id: "provider-1" }],
@@ -482,8 +490,146 @@ describe("buildProvidersTableRows", () => {
expect(ouChild.subRows![0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER);
});
it("builds each organization's hierarchy from its own parent link shape", () => {
// Given — two organizations whose nodes are serialized differently: org-a
// carries the canonical `parent` relationship, org-b only the legacy
// `parent_external_id` attribute. Deciding the mode across the whole
// collection made org-b read every parent as null, so its intermediate node
// ended up empty and was filtered away.
const providers = [
toProviderRow(providersResponse.data[0]),
toProviderRow(providersResponse.data[1]),
];
// When
const rows = buildProvidersTableRows({
providers,
organizations: [
{
id: "org-a",
type: "organizations",
attributes: {
name: "Canonical Organization",
org_type: "aws",
external_id: "o-aaaa",
metadata: {},
root_external_id: "r-a",
},
relationships: {},
},
{
id: "org-b",
type: "organizations",
attributes: {
name: "Legacy Organization",
org_type: "gcp",
external_id: "o-bbbb",
metadata: {},
root_external_id: "r-b",
},
relationships: {},
},
],
organizationNodes: [
{
id: "a-parent",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "A Parent",
external_id: "ou-a-parent",
parent_external_id: null,
metadata: {},
},
relationships: {
organization: { data: { type: "organizations", id: "org-a" } },
parent: { data: null },
},
},
{
id: "a-child",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "A Child",
external_id: "ou-a-child",
parent_external_id: null,
metadata: {},
},
relationships: {
organization: { data: { type: "organizations", id: "org-a" } },
parent: { data: { type: "organization-nodes", id: "a-parent" } },
providers: { data: [{ type: "providers", id: "provider-1" }] },
},
},
{
id: "b-parent",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.FOLDER,
name: "B Parent",
external_id: "folder-b-parent",
parent_external_id: "r-b",
metadata: {},
},
relationships: {
organization: { data: { type: "organizations", id: "org-b" } },
},
},
{
id: "b-child",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.FOLDER,
name: "B Child",
external_id: "folder-b-child",
parent_external_id: "folder-b-parent",
metadata: {},
},
relationships: {
organization: { data: { type: "organizations", id: "org-b" } },
providers: { data: [{ type: "providers", id: "provider-2" }] },
},
},
],
isCloud: true,
});
// Then — both organizations keep their full two-level chain, and no provider
// is stranded at the top level.
expect(rows).toHaveLength(2);
expect(
rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.ORGANIZATION),
).toBe(true);
for (const [organizationId, parentName, childName] of [
["org-a", "A Parent", "A Child"],
["org-b", "B Parent", "B Child"],
]) {
const orgRow = rows.find((row) => row.id === organizationId)!;
expect(orgRow.subRows).toHaveLength(1);
const parentRow = orgRow.subRows![0];
if (!isProvidersOrganizationRow(parentRow)) {
throw new Error(`Expected ${parentName} to be an organization row`);
}
expect(parentRow.name).toBe(parentName);
expect(parentRow.subRows).toHaveLength(1);
const childRow = parentRow.subRows[0];
if (!isProvidersOrganizationRow(childRow)) {
throw new Error(`Expected ${childName} to be an organization row`);
}
expect(childRow.name).toBe(childName);
expect(childRow.subRows).toHaveLength(1);
expect(childRow.subRows[0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER);
}
});
it("does not duplicate providers that appear in both org relationships and OU assignments", () => {
// Given — provider-1 is linked to org-1 AND assigned to ou-1
// Given — provider-1 is linked to org-1 AND assigned to ou-1.
// Uses the deprecated `organization_unit` alias (rather than the canonical
// `organization_node`) to keep coverage of the source's alias fallback.
const providers = [
toProviderRow(providersResponse.data[0], {
relationships: {
@@ -519,11 +665,12 @@ describe("buildProvidersTableRows", () => {
},
},
],
organizationUnits: [
organizationNodes: [
{
id: "ou-1",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Security OU",
external_id: "ou-security",
parent_external_id: "r-root",
@@ -563,7 +710,7 @@ describe("buildProvidersTableRows", () => {
organization: {
data: { type: "organizations", id: "org-1" },
},
organization_unit: {
organization_node: {
data: null,
},
},
@@ -591,7 +738,7 @@ describe("buildProvidersTableRows", () => {
},
},
],
organizationUnits: [],
organizationNodes: [],
isCloud: true,
});
@@ -613,7 +760,7 @@ describe("buildProvidersTableRows", () => {
organization: {
data: null,
},
organization_unit: {
organization_node: {
data: null,
},
},
@@ -641,13 +788,13 @@ describe("buildProvidersTableRows", () => {
{ type: "providers", id: "provider-2" },
],
},
organizational_units: {
organization_nodes: {
data: [],
},
},
},
],
organizationUnits: [],
organizationNodes: [],
isCloud: true,
});
@@ -665,6 +812,56 @@ describe("buildProvidersTableRows", () => {
),
).toBe(true);
expect(orgRow.providerIds).toEqual(["provider-1", "provider-2"]);
// Org row carries the per-organization orgType from `attributes.org_type`.
expect(orgRow.orgType).toBe("aws");
});
it("groups organizations of a type that has no onboarding flow, keeping their own type", () => {
// Display covers every organization type the API reports; only onboarding is
// limited to aws|gcp. The row must carry the real type (never coerced to
// aws), so its labels and actions can be derived from it.
const providers = providersResponse.data.map((provider) =>
toProviderRow(provider, {
relationships: {
...provider.relationships,
organization: { data: { id: "org-az", type: "organizations" } },
organization_node: { data: null },
},
}),
);
// When
const rows = buildProvidersTableRows({
providers,
organizations: [
{
id: "org-az",
type: "organizations",
attributes: {
name: "Contoso Tenant",
org_type: "azure",
external_id: "tenant-az",
metadata: {},
root_external_id: null,
},
relationships: {
providers: { data: [] },
organization_nodes: { data: [] },
},
},
],
organizationNodes: [],
isCloud: true,
});
// Then
expect(rows).toHaveLength(1);
const orgRow = rows[0];
if (!isProvidersOrganizationRow(orgRow)) {
throw new Error("Expected organization row");
}
expect(orgRow.orgType).toBe("azure");
expect(orgRow.subRows).toHaveLength(2);
});
it("keeps organization relationship provider ids even when providers are not in the visible page", () => {
@@ -701,13 +898,13 @@ describe("buildProvidersTableRows", () => {
{ type: "providers", id: "provider-not-in-page" },
],
},
organizational_units: {
organization_nodes: {
data: [],
},
},
},
],
organizationUnits: [],
organizationNodes: [],
isCloud: true,
});
@@ -741,7 +938,7 @@ describe("loadProvidersAccountsViewData", () => {
organizationsActionsMock.listOrganizationsSafe,
).not.toHaveBeenCalled();
expect(
organizationsActionsMock.listOrganizationUnitsSafe,
organizationsActionsMock.listOrganizationNodesSafe,
).not.toHaveBeenCalled();
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
["Status"],
@@ -762,10 +959,10 @@ describe("loadProvidersAccountsViewData", () => {
? { type: "organizations", id: "org-1" }
: null,
},
organization_unit: {
organization_node: {
data:
provider.id === "provider-1"
? { type: "organizational-units", id: "ou-1" }
? { type: "organization-nodes", id: "ou-1" }
: null,
},
},
@@ -788,12 +985,13 @@ describe("loadProvidersAccountsViewData", () => {
},
],
});
organizationsActionsMock.listOrganizationUnitsSafe.mockResolvedValue({
organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({
data: [
{
id: "ou-1",
type: "organizational-units",
type: "organization-nodes",
attributes: {
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Security OU",
external_id: "ou-security",
parent_external_id: "r-root",
@@ -823,7 +1021,7 @@ describe("loadProvidersAccountsViewData", () => {
organizationsActionsMock.listOrganizationsSafe,
).toHaveBeenCalledTimes(1);
expect(
organizationsActionsMock.listOrganizationUnitsSafe,
organizationsActionsMock.listOrganizationNodesSafe,
).toHaveBeenCalledTimes(1);
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
["Status"],
@@ -832,14 +1030,17 @@ describe("loadProvidersAccountsViewData", () => {
});
it("falls back to empty cloud grouping data when organizations endpoints fail", async () => {
// Given
// Given — both hierarchy fetches fail (flagged with `error: true`), which is
// distinct from a genuinely-empty hierarchy.
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [],
error: true,
});
organizationsActionsMock.listOrganizationUnitsSafe.mockResolvedValue({
organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({
data: [],
error: true,
});
scansActionsMock.getScans.mockResolvedValue({ data: [] });
@@ -849,7 +1050,7 @@ describe("loadProvidersAccountsViewData", () => {
isCloud: true,
});
// Then
// Then — providers render flat and the degraded-view notice is signaled.
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
["Status"],
);
@@ -857,6 +1058,67 @@ describe("loadProvidersAccountsViewData", () => {
expect(
viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER),
).toBe(true);
expect(viewData.hierarchyStatus).toBe(HIERARCHY_STATUS.UNAVAILABLE);
});
it("keeps organization grouping when only the organization-nodes fetch fails", async () => {
// Given organizations that read fine but a nodes fetch that did not. The
// provider carries its `organization` relationship, so the organization row
// genuinely has something to group — otherwise the row is dropped for
// having no providers and this test would pass either way.
providersActionsMock.getProviders.mockResolvedValue({
...providersResponse,
data: providersResponse.data.map((provider) => ({
...provider,
relationships: {
...provider.relationships,
organization: {
data:
provider.id === "provider-1"
? { type: "organizations", id: "org-1" }
: null,
},
},
})),
});
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [
{
id: "org-1",
type: "organizations",
attributes: {
name: "Root Organization",
org_type: "aws",
external_id: "o-root",
metadata: {},
root_external_id: "r-root",
},
relationships: {},
},
],
});
organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({
data: [],
error: true,
});
scansActionsMock.getScans.mockResolvedValue({ data: [] });
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: true,
});
// Then the notice is raised, but the organization row survives — it is the
// only way to reach Edit Organization Name / Update Credentials / Delete.
expect(viewData.hierarchyStatus).toBe(HIERARCHY_STATUS.UNAVAILABLE);
const organizationRow = viewData.rows.find(
(row) => row.rowType === PROVIDERS_ROW_TYPE.ORGANIZATION,
);
expect(organizationRow).toBeDefined();
expect(organizationRow?.name).toBe("Root Organization");
});
it("surfaces the real cadence (not a hardcoded label) from a configured schedule with no materialized scan yet", async () => {
+171 -104
View File
@@ -1,7 +1,7 @@
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import {
listOrganizationNodesSafe,
listOrganizationsSafe,
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getAllProviders, getProviders } from "@/actions/providers";
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
@@ -17,15 +17,18 @@ import {
isScheduleConfigured,
} from "@/lib/schedules";
import {
CollectionFetch,
FilterEntity,
FilterOption,
OrganizationListResponse,
OrganizationUnitListResponse,
OrganizationUnitResource,
OrganizationNodeResource,
OrganizationResource,
OrganizationType,
ProvidersApiResponse,
SearchParamsProps,
} from "@/types";
import {
HIERARCHY_STATUS,
HierarchyStatus,
PROVIDERS_GROUP_KIND,
PROVIDERS_PAGE_FILTER,
PROVIDERS_ROW_TYPE,
@@ -176,6 +179,8 @@ const enrichProviders = (
const createOrganizationRow = ({
groupKind,
orgType,
kind,
id,
name,
externalId,
@@ -186,6 +191,8 @@ const createOrganizationRow = ({
}: {
externalId: string | null;
groupKind: ProvidersOrganizationRow["groupKind"];
orgType: OrganizationType;
kind?: ProvidersOrganizationRow["kind"];
id: string;
name: string;
organizationId: string | null;
@@ -196,6 +203,8 @@ const createOrganizationRow = ({
id,
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
groupKind,
orgType,
kind,
name,
externalId,
organizationId,
@@ -217,10 +226,10 @@ function getRelationshipProviderIds(
return relationships?.providers?.data?.map((provider) => provider.id) ?? [];
}
function getOrganizationUnitParentId(
organizationUnit: OrganizationUnitResource,
function getOrganizationNodeParentId(
organizationNode: OrganizationNodeResource,
): string | null {
return organizationUnit.relationships.parent?.data?.id ?? null;
return organizationNode.relationships.parent?.data?.id ?? null;
}
function getProviderRowsByIds({
@@ -235,6 +244,46 @@ function getProviderRowsByIds({
.filter((provider): provider is ProvidersProviderRow => Boolean(provider));
}
/**
* Resolves a group's direct providers: the `providers` relationship when the API
* serves one, the reverse-lookup map otherwise.
*/
function resolveProviderRowsAndIds({
relationships,
fallbackProviders,
providerLookup,
excludeIds,
}: {
relationships: Parameters<typeof getRelationshipProviderIds>[0];
fallbackProviders: ProvidersProviderRow[];
providerLookup: Map<string, ProvidersProviderRow>;
excludeIds?: ReadonlySet<string>;
}): { providerRows: ProvidersProviderRow[]; directProviderIds: string[] } {
const isIncluded = (provider: ProvidersProviderRow) =>
!excludeIds?.has(provider.id);
const relationshipProviderIds = getRelationshipProviderIds(relationships);
const rowsFromRelationships = getProviderRowsByIds({
providerIds: relationshipProviderIds,
providerLookup,
}).filter(isIncluded);
if (rowsFromRelationships.length > 0) {
return {
providerRows: rowsFromRelationships,
// The relationship ids, not the resolved rows: the lookup holds only the
// current page, while the id set drives the group's counts and filtering.
directProviderIds: relationshipProviderIds,
};
}
const providerRows = fallbackProviders.filter(isIncluded);
return {
providerRows,
directProviderIds: providerRows.map((provider) => provider.id),
};
}
function dedupeIds(ids: string[]): string[] {
return Array.from(new Set(ids));
}
@@ -245,32 +294,35 @@ function collectOrganizationRowProviderIds(
return dedupeIds(rows.flatMap((row) => row.providerIds));
}
function getOrganizationUnitRelationshipId(
function getOrganizationNodeRelationshipId(
provider: ProvidersProviderRow,
): string | null {
return (
provider.relationships.organization_node?.data?.id ??
provider.relationships.organization_unit?.data?.id ??
provider.relationships.organizational_unit?.data?.id ??
null
);
}
function buildOrganizationUnitRows({
function buildOrganizationNodeRows({
organizationId,
organizationUnits,
organizationType,
organizationNodes,
providerLookup,
providersByOrganizationUnitId,
providersByOrganizationNodeId,
useParentIdRelationships,
parentExternalId,
parentOrganizationUnitId,
parentOrganizationNodeId,
maxDepth = 10,
}: {
organizationId: string;
organizationUnits: OrganizationUnitResource[];
organizationType: OrganizationType;
organizationNodes: OrganizationNodeResource[];
parentExternalId: string | null;
parentOrganizationUnitId: string | null;
parentOrganizationNodeId: string | null;
providerLookup: Map<string, ProvidersProviderRow>;
providersByOrganizationUnitId: Map<string, ProvidersProviderRow[]>;
providersByOrganizationNodeId: Map<string, ProvidersProviderRow[]>;
useParentIdRelationships: boolean;
maxDepth?: number;
}): ProvidersOrganizationRow[] {
@@ -278,65 +330,63 @@ function buildOrganizationUnitRows({
return [];
}
return organizationUnits
return organizationNodes
.filter(
(organizationUnit) =>
organizationUnit.relationships.organization.data.id ===
(organizationNode) =>
organizationNode.relationships.organization.data.id ===
organizationId &&
(useParentIdRelationships
? getOrganizationUnitParentId(organizationUnit) ===
parentOrganizationUnitId
: organizationUnit.attributes.parent_external_id ===
? getOrganizationNodeParentId(organizationNode) ===
parentOrganizationNodeId
: organizationNode.attributes.parent_external_id ===
parentExternalId),
)
.map((organizationUnit) => {
const childOrganizationUnitRows = buildOrganizationUnitRows({
.map((organizationNode) => {
const childOrganizationNodeRows = buildOrganizationNodeRows({
organizationId,
organizationUnits,
parentOrganizationUnitId: organizationUnit.id,
parentExternalId: organizationUnit.attributes.external_id,
organizationType,
organizationNodes,
parentOrganizationNodeId: organizationNode.id,
parentExternalId: organizationNode.attributes.external_id,
providerLookup,
providersByOrganizationUnitId,
providersByOrganizationNodeId,
useParentIdRelationships,
maxDepth: maxDepth - 1,
});
const providerRowsFromRelationships = getProviderRowsByIds({
providerIds: getRelationshipProviderIds(organizationUnit.relationships),
const { providerRows, directProviderIds } = resolveProviderRowsAndIds({
relationships: organizationNode.relationships,
fallbackProviders:
providersByOrganizationNodeId.get(organizationNode.id) ?? [],
providerLookup,
});
const providerRows =
providerRowsFromRelationships.length > 0
? providerRowsFromRelationships
: (providersByOrganizationUnitId.get(organizationUnit.id) ?? []);
const subRows = [...childOrganizationUnitRows, ...providerRows];
const directProviderIds =
providerRowsFromRelationships.length > 0
? getRelationshipProviderIds(organizationUnit.relationships)
: providerRows.map((provider) => provider.id);
const subRows = [...childOrganizationNodeRows, ...providerRows];
const childProviderIds = collectOrganizationRowProviderIds(
childOrganizationUnitRows,
childOrganizationNodeRows,
);
return createOrganizationRow({
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
id: organizationUnit.id,
name: organizationUnit.attributes.name,
externalId: organizationUnit.attributes.external_id,
orgType: organizationType,
kind: organizationNode.attributes.kind,
id: organizationNode.id,
name: organizationNode.attributes.name,
externalId: organizationNode.attributes.external_id,
organizationId,
parentExternalId: organizationUnit.attributes.parent_external_id,
parentExternalId:
organizationNode.attributes.parent_external_id ?? null,
providerIds: dedupeIds([...childProviderIds, ...directProviderIds]),
subRows,
});
})
.filter(
(organizationUnitRow) => organizationUnitRow.providerIds.length > 0,
(organizationNodeRow) => organizationNodeRow.providerIds.length > 0,
);
}
export function buildProvidersTableRows({
isCloud,
organizations,
organizationUnits,
organizationNodes,
providers,
}: ProvidersTableRowsInput): ProvidersTableRow[] {
if (!isCloud) {
@@ -347,7 +397,7 @@ export function buildProvidersTableRows({
providers.map((provider) => [provider.id, provider] as const),
);
const providersByOrganizationId = new Map<string, ProvidersProviderRow[]>();
const providersByOrganizationUnitId = new Map<
const providersByOrganizationNodeId = new Map<
string,
ProvidersProviderRow[]
>();
@@ -355,15 +405,15 @@ export function buildProvidersTableRows({
for (const provider of providers) {
const organizationId =
provider.relationships.organization?.data?.id ?? null;
const organizationUnitId = getOrganizationUnitRelationshipId(provider);
const organizationNodeId = getOrganizationNodeRelationshipId(provider);
if (organizationUnitId) {
const organizationUnitProviders =
providersByOrganizationUnitId.get(organizationUnitId) ?? [];
organizationUnitProviders.push(provider);
providersByOrganizationUnitId.set(
organizationUnitId,
organizationUnitProviders,
if (organizationNodeId) {
const organizationNodeProviders =
providersByOrganizationNodeId.get(organizationNodeId) ?? [];
organizationNodeProviders.push(provider);
providersByOrganizationNodeId.set(
organizationNodeId,
organizationNodeProviders,
);
continue;
}
@@ -376,68 +426,74 @@ export function buildProvidersTableRows({
}
}
const useParentIdRelationships = organizationUnits.some(
(organizationUnit) => organizationUnit.relationships.parent !== undefined,
);
// Build a set of provider IDs that are assigned to OUs, so we can
// Build a set of provider IDs that are assigned to nodes, so we can
// exclude them from the org's direct children and avoid duplication.
const providersAssignedToOu = new Set(
Array.from(providersByOrganizationUnitId.values()).flatMap((providers) =>
const providersAssignedToNode = new Set(
Array.from(providersByOrganizationNodeId.values()).flatMap((providers) =>
providers.map((p) => p.id),
),
);
const organizationRows = organizations
.map((organization) => {
const organizationUnitRows = buildOrganizationUnitRows({
const organizationType = organization.attributes.org_type;
// Which parent link to follow is decided per organization, not across the
// whole collection: one organization serving `parent` would otherwise make
// every organization read its nodes that way, and those lacking the
// relationship would resolve every parent to null — collapsing their nodes
// to the root, emptying the intermediate rows and dropping them entirely.
const useParentIdRelationships = organizationNodes.some(
(organizationNode) =>
organizationNode.relationships.organization.data.id ===
organization.id &&
organizationNode.relationships.parent !== undefined,
);
const organizationNodeRows = buildOrganizationNodeRows({
organizationId: organization.id,
organizationUnits,
parentOrganizationUnitId: null,
organizationType,
organizationNodes,
parentOrganizationNodeId: null,
parentExternalId: organization.attributes.root_external_id,
providerLookup,
providersByOrganizationUnitId,
providersByOrganizationNodeId,
useParentIdRelationships,
});
// Collect all provider IDs already placed inside OUs to avoid duplication
// at the org level. This covers both relationship-based and fallback assignments.
const providersInOus = new Set<string>();
function collectOuProviderIds(rows: ProvidersTableRow[]) {
// Collect all provider IDs already placed inside nodes to avoid
// duplication at the org level. Covers relationship + fallback assignments.
const providersInNodes = new Set<string>();
function collectNodeProviderIds(rows: ProvidersTableRow[]) {
for (const row of rows) {
if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
providersInOus.add(row.id);
providersInNodes.add(row.id);
} else {
collectOuProviderIds(row.subRows);
collectNodeProviderIds(row.subRows);
}
}
}
collectOuProviderIds(organizationUnitRows);
collectNodeProviderIds(organizationNodeRows);
const organizationProvidersFromRelationships = getProviderRowsByIds({
providerIds: getRelationshipProviderIds(organization.relationships),
providerLookup,
}).filter(
(provider) =>
!providersAssignedToOu.has(provider.id) &&
!providersInOus.has(provider.id),
);
const organizationProviders =
organizationProvidersFromRelationships.length > 0
? organizationProvidersFromRelationships
: (providersByOrganizationId.get(organization.id) ?? []).filter(
(provider) => !providersInOus.has(provider.id),
);
const subRows = [...organizationProviders, ...organizationUnitRows];
const directProviderIds =
organizationProvidersFromRelationships.length > 0
? getRelationshipProviderIds(organization.relationships)
: organizationProviders.map((provider) => provider.id);
const organizationUnitProviderIds =
collectOrganizationRowProviderIds(organizationUnitRows);
// One exclude set for both branches: the fallback map skips providers that
// carry a node relationship, so `providersAssignedToNode` can only ever
// match on the relationship branch.
const { providerRows: organizationProviders, directProviderIds } =
resolveProviderRowsAndIds({
relationships: organization.relationships,
fallbackProviders:
providersByOrganizationId.get(organization.id) ?? [],
providerLookup,
excludeIds: new Set([
...Array.from(providersAssignedToNode),
...Array.from(providersInNodes),
]),
});
const subRows = [...organizationProviders, ...organizationNodeRows];
const organizationNodeProviderIds =
collectOrganizationRowProviderIds(organizationNodeRows);
return createOrganizationRow({
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
orgType: organizationType,
id: organization.id,
name: organization.attributes.name,
externalId: organization.attributes.external_id,
@@ -445,7 +501,7 @@ export function buildProvidersTableRows({
parentExternalId: organization.attributes.root_external_id,
providerIds: dedupeIds([
...directProviderIds,
...organizationUnitProviderIds,
...organizationNodeProviderIds,
]),
subRows,
});
@@ -493,12 +549,13 @@ export async function loadProvidersAccountsViewData({
delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
const emptyOrganizationsResponse: OrganizationListResponse = {
data: [],
};
const emptyOrganizationUnitsResponse: OrganizationUnitListResponse = {
const emptyOrganizationsResponse: CollectionFetch<OrganizationResource> = {
data: [],
};
const emptyOrganizationNodesResponse: CollectionFetch<OrganizationNodeResource> =
{
data: [],
};
const [
providersResponse,
@@ -506,7 +563,7 @@ export async function loadProvidersAccountsViewData({
allProviderGroupsResponse,
schedulesResponse,
organizationsResponse,
organizationUnitsResponse,
organizationNodesResponse,
] = await Promise.all([
resolveActionResult(
getProviders({
@@ -529,20 +586,29 @@ export async function loadProvidersAccountsViewData({
? listOrganizationsSafe()
: Promise.resolve(emptyOrganizationsResponse),
isCloud
? listOrganizationUnitsSafe()
: Promise.resolve(emptyOrganizationUnitsResponse),
? listOrganizationNodesSafe()
: Promise.resolve(emptyOrganizationNodesResponse),
]);
const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse);
const orgs = organizationsResponse?.data ?? [];
const ous = organizationUnitsResponse?.data ?? [];
const orgs = organizationsResponse.data;
const nodes = organizationNodesResponse.data;
const providers = enrichProviders(providersResponse, schedulesByProviderId);
const hierarchyStatus: HierarchyStatus =
isCloud &&
(Boolean(organizationsResponse.error) ||
Boolean(organizationNodesResponse.error))
? HIERARCHY_STATUS.UNAVAILABLE
: HIERARCHY_STATUS.AVAILABLE;
// Whatever was read is still rendered; the notice is worded for both partial
// shapes rather than promising a flat list.
const rows = buildProvidersTableRows({
isCloud,
organizations: orgs,
organizationUnits: ous,
organizationNodes: nodes,
providers,
});
@@ -552,6 +618,7 @@ export async function loadProvidersAccountsViewData({
providers: allProvidersResponse?.data ?? [],
providerGroups: allProviderGroupsResponse?.data ?? [],
rows,
hierarchyStatus,
};
}
@@ -0,0 +1,64 @@
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ProvidersAccountsView } from "@/components/providers";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import {
SCAN_CONFIGURATION_LIST_STATUS,
type ScanConfigurationListState,
} from "@/types/scan-configurations";
import { loadProvidersAccountsViewData } from "./providers-page.utils";
const loadScanConfigs = async (
isCloud: boolean,
): Promise<ScanConfigurationListState> => {
if (!isCloud) {
return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] };
}
try {
return {
status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
data: await listScanConfigurations(),
};
} catch (error) {
console.error("Error loading provider scan configurations:", error);
return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] };
}
};
export const ProvidersTabContent = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
// client component and injects `useMemoCache`, which needs a React dispatcher.
// An async server component renders once per request, so there is nothing to
// memoize — and the injected hook makes it uncallable outside a render, which
// is exactly how the browser-mode tests mount it.
"use no memo";
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
searchParams,
isCloud: isCloudEnvironment,
}),
loadScanConfigs(isCloudEnvironment),
]);
return (
<ProvidersAccountsView
isCloud={isCloudEnvironment}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
metadata={providersView.metadata}
rows={providersView.rows}
hierarchyStatus={providersView.hierarchyStatus}
scanConfigs={scanConfigsState.data}
scanConfigStatus={scanConfigsState.status}
/>
);
};
@@ -0,0 +1 @@
GCP organization onboarding in the provider wizard: add every project of an organization at once, choosing which discovered projects to include (Prowler Cloud only)
@@ -0,0 +1 @@
Providers page groups GCP projects under their organization and folders
@@ -0,0 +1 @@
Warning before replacing an organization credential or deleting an organization, listing the providers affected
@@ -1,13 +1,17 @@
"use client";
import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
import {
deleteOrganization,
deleteOrganizationalUnit,
deleteOrganizationNode,
} from "@/actions/organizations/organizations";
import { DeleteIcon } from "@/components/icons";
import { pollTaskCompletion } from "@/components/providers/organizations/org-account-selection.utils";
import { Button, useToast } from "@/components/shadcn";
import { getNodeLabel } from "@/lib/organizations";
import { NodeKind, OrganizationType } from "@/types/organizations";
import {
PROVIDERS_GROUP_KIND,
ProvidersGroupKind,
@@ -17,74 +21,129 @@ interface DeleteOrganizationFormProps {
id: string;
name: string;
variant: ProvidersGroupKind;
orgType: OrganizationType;
kind?: NodeKind;
/** Providers that cascade-delete with this entity. */
providerCount: number;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
function extractTaskId(result: unknown): string | null {
if (
result &&
typeof result === "object" &&
"data" in result &&
result.data &&
typeof result.data === "object" &&
"id" in result.data &&
typeof (result.data as { id: unknown }).id === "string"
) {
return (result.data as { id: string }).id;
}
return null;
}
export function DeleteOrganizationForm({
id,
name,
variant,
orgType,
kind,
providerCount,
setIsOpen,
}: DeleteOrganizationFormProps) {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const isOrg = variant === PROVIDERS_GROUP_KIND.ORGANIZATION;
const entityLabel = isOrg ? "organization" : "organizational unit";
const entityLabel = isOrg
? "organization"
: getNodeLabel(orgType, kind).toLowerCase();
const handleDelete = async () => {
setIsLoading(true);
const result = isOrg
? await deleteOrganization(id)
: await deleteOrganizationalUnit(id);
: await deleteOrganizationNode(id);
if (result?.errors?.length || result?.error) {
setIsLoading(false);
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: result.errors?.[0]?.detail ?? result.error,
});
return;
}
// The task completes once the per-provider deletions are *dispatched*, and any
// rollback runs in an errback task with no id to poll — so a completed task
// means "accepted", never "done". Both outcomes refetch, because a rollback
// restores the subtree and its rows have to reappear.
const taskId = extractTaskId(result);
const taskResult = taskId
? await pollTaskCompletion(taskId)
: { success: true as const };
setIsLoading(false);
if (result?.errors && result.errors.length > 0) {
const error = result.errors[0];
if (!taskResult.success) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: `${error.detail}`,
});
} else if (result?.error) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: result.error,
});
} else {
toast({
title: "Success!",
description: `The ${entityLabel} "${name}" was removed successfully.`,
title: "Deletion did not complete",
description:
taskResult.error ??
`The ${entityLabel} "${name}" could not be deleted.`,
});
setIsOpen(false);
router.refresh();
return;
}
toast({
title: "Deletion started",
description: `Prowler is deleting the ${entityLabel} "${name}" and its providers. If any part of it fails, the rows reappear on a later refresh.`,
});
setIsOpen(false);
router.refresh();
};
return (
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setIsOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<div className="flex flex-col gap-4">
{providerCount > 0 && (
<p className="text-text-neutral-secondary text-sm">
This will also delete{" "}
<strong>
{providerCount} {providerCount === 1 ? "provider" : "providers"}
</strong>{" "}
grouped under this {entityLabel}.
</p>
)}
<Button
type="button"
variant="destructive"
size="lg"
disabled={isLoading}
onClick={handleDelete}
>
{!isLoading && <DeleteIcon size={24} />}
{isLoading ? "Loading" : "Delete"}
</Button>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setIsOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isLoading}
onClick={handleDelete}
>
{!isLoading && <DeleteIcon size={24} />}
{isLoading ? "Loading" : "Delete"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import { Clock } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
interface DiscoveryTimeoutNoticeProps {
onKeepWaiting: () => void;
onRetry: () => void;
}
/**
* The worker keeps running past the client's polling budget, so a timeout offers
* two actions: keep waiting (resume the same discovery) or retry (a fresh one).
*/
export function DiscoveryTimeoutNotice({
onKeepWaiting,
onRetry,
}: DiscoveryTimeoutNoticeProps) {
return (
<Alert variant="warning">
<Clock className="size-4" />
<AlertDescription>
<div className="flex flex-col gap-3">
<p>
Discovery is taking longer than expected. It may still be running in
the background.
</p>
<div className="flex gap-3">
<Button
type="button"
variant="default"
size="sm"
onClick={onKeepWaiting}
>
Keep waiting
</Button>
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
</div>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { GcpMethodSelector } from "./gcp-method-selector";
describe("GcpMethodSelector", () => {
afterEach(() => {
vi.unstubAllEnvs();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("opens the GCP Organizations upgrade in Local Server", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
const onSelectOrganizations = vi.fn();
// When
render(
<GcpMethodSelector
onSelectSingle={vi.fn()}
onSelectOrganizations={onSelectOrganizations}
/>,
);
// Then
await user.click(
screen.getByRole("radio", {
name: /add multiple projects with gcp organization/i,
}),
);
expect(onSelectOrganizations).not.toHaveBeenCalled();
expect(screen.getByText("Cloud")).toBeVisible();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS,
);
});
it("enters the GCP org flow in Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
const onSelectOrganizations = vi.fn();
// When
render(
<GcpMethodSelector
onSelectSingle={vi.fn()}
onSelectOrganizations={onSelectOrganizations}
/>,
);
// Then
await user.click(
screen.getByRole("radio", {
name: /add multiple projects with gcp organization/i,
}),
);
expect(onSelectOrganizations).toHaveBeenCalledTimes(1);
expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
});
});
@@ -0,0 +1,50 @@
"use client";
import { Box, Boxes } from "lucide-react";
import { RadioCard } from "@/components/providers/radio-card";
import { Badge } from "@/components/shadcn/badge/badge";
import { isCloud } from "@/lib/shared/env";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
interface GcpMethodSelectorProps {
onSelectSingle: () => void;
onSelectOrganizations: () => void;
}
export function GcpMethodSelector({
onSelectSingle,
onSelectOrganizations,
}: GcpMethodSelectorProps) {
const isCloudEnv = isCloud();
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
return (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-sm">
Select a method to add your projects to Prowler.
</p>
<RadioCard
icon={Box}
title="Add A Single GCP Project"
onClick={onSelectSingle}
/>
<RadioCard
icon={Boxes}
title="Add Multiple Projects With GCP Organization"
onClick={() =>
isCloudEnv
? onSelectOrganizations()
: openCloudUpgrade(CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS)
}
>
{!isCloudEnv && <Badge variant="cloud">Cloud</Badge>}
</RadioCard>
</div>
);
}
@@ -0,0 +1,491 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { GCPProviderBadge } from "@/components/icons/providers-badge";
import { RadioCard } from "@/components/providers/radio-card";
import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
import { WIZARD_FOOTER_ACTION_TYPE } from "@/components/providers/wizard/steps/footer-controls";
import type { OrgWizardIntent } from "@/components/providers/wizard/types";
import { ORG_WIZARD_INTENT } from "@/components/providers/wizard/types";
import {
WizardInputField,
WizardTextareaField,
} from "@/components/providers/workflow/forms/fields";
import { useToast } from "@/components/shadcn";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Form } from "@/components/shadcn/form";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useOrgSetupStore } from "@/store/organizations/store";
import type { OrgSetupPhase } from "@/types/organizations";
import {
ORG_SECRET_TYPE,
ORG_SETUP_PHASE,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import { DiscoveryTimeoutNotice } from "./discovery-timeout-notice";
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
import { SecretReplaceWarningModal } from "./secret-replace-warning-modal";
const GCP_ORG_ID_PATTERN = /^[0-9]+$/;
function isJsonObject(value: string): boolean {
try {
const parsed = JSON.parse(value);
return (
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
);
} catch {
return false;
}
}
const gcpOrgSetupSchema = z
.object({
organizationName: z.string().trim().optional(),
gcpOrgId: z
.string()
.trim()
.min(1, "Organization ID is required")
.regex(
GCP_ORG_ID_PATTERN,
"Must be a numeric Google Cloud organization ID (e.g., 123456789012)",
),
credentialMethod: z.enum([
ORG_SECRET_TYPE.SERVICE_ACCOUNT,
ORG_SECRET_TYPE.STATIC,
]),
serviceAccountKey: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
refreshToken: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.credentialMethod === ORG_SECRET_TYPE.SERVICE_ACCOUNT) {
if (!data.serviceAccountKey || !isJsonObject(data.serviceAccountKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON format. Please provide a valid JSON object.",
path: ["serviceAccountKey"],
});
}
return;
}
for (const [field, label] of [
["clientId", "Client ID"],
["clientSecret", "Client Secret"],
["refreshToken", "Refresh Token"],
] as const) {
if (!data[field]?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${label} is required`,
path: [field],
});
}
}
});
type GcpOrgSetupFormData = z.infer<typeof gcpOrgSetupSchema>;
interface GcpOrgSetupFormInitialValues {
organizationName: string;
gcpOrgId: string;
}
interface GcpOrgSetupFormProps {
onBack: () => void;
onClose?: () => void;
onNext: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
onPhaseChange: (phase: OrgSetupPhase) => void;
initialPhase?: OrgSetupPhase;
initialValues?: GcpOrgSetupFormInitialValues;
intent?: OrgWizardIntent;
}
export function GcpOrgSetupForm({
onBack,
onClose,
onNext,
onFooterChange,
onPhaseChange,
initialPhase = ORG_SETUP_PHASE.DETAILS,
initialValues,
intent = ORG_WIZARD_INTENT.FULL,
}: GcpOrgSetupFormProps) {
const { organizationId } = useOrgSetupStore();
const { toast } = useToast();
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
const [isSaving, setIsSaving] = useState(false);
const formId = "gcp-org-wizard-setup-form";
const isReadOnlyOrgId = Boolean(initialValues?.gcpOrgId);
const form = useForm<GcpOrgSetupFormData>({
resolver: zodResolver(gcpOrgSetupSchema),
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
organizationName: initialValues?.organizationName ?? "",
gcpOrgId: initialValues?.gcpOrgId ?? "",
credentialMethod: ORG_SECRET_TYPE.SERVICE_ACCOUNT,
serviceAccountKey: "",
clientId: "",
clientSecret: "",
refreshToken: "",
},
});
const {
control,
handleSubmit,
formState: { isSubmitting, isValid },
setError,
setValue,
watch,
} = form;
const gcpOrgId = watch("gcpOrgId") || "";
const isOrgIdValid = GCP_ORG_ID_PATTERN.test(gcpOrgId.trim());
const credentialMethod = watch("credentialMethod");
const {
apiError,
setApiError,
submitOrganizationSetup,
replaceSecretWarning,
confirmSecretReplace,
cancelSecretReplace,
discoveryTimedOut,
discoveryFailed,
isSubmissionPending,
keepWaitingForDiscovery,
retryDiscovery,
} = useOrgSetupSubmission({
// Unlike an AWS role secret, a GCP secret echoes no external id.
stackSetExternalId: "",
onNext,
// Only the fields this form renders: a `setError` on an unregistered field
// would render nowhere, so anything else goes back for the hook to banner.
setFieldError: (field, message) => {
switch (field) {
case "organizationName":
case "gcpOrgId":
case "serviceAccountKey":
case "clientId":
case "clientSecret":
case "refreshToken":
setError(field, { message });
return true;
default:
return false;
}
},
});
// `isSubmitting` only covers a submit react-hook-form started itself, not the
// chain re-entered by confirming a replacement, keeping waiting or retrying.
const isBusy = isSubmitting || isSubmissionPending;
useEffect(() => {
onPhaseChange(setupPhase);
}, [onPhaseChange, setupPhase]);
useEffect(() => {
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
const isEditName = intent === ORG_WIZARD_INTENT.EDIT_NAME;
onFooterChange({
showBack: true,
backLabel: "Back",
onBack,
showAction: true,
actionLabel: isEditName ? "Save" : "Next",
actionDisabled: isEditName ? isSaving : !isOrgIdValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
return;
}
const isEditCredentials = intent === ORG_WIZARD_INTENT.EDIT_CREDENTIALS;
onFooterChange({
showBack: !isEditCredentials,
backLabel: "Back",
backDisabled: isBusy,
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
showAction: true,
actionLabel: "Authenticate",
actionDisabled: isBusy || !isValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [
formId,
intent,
isBusy,
isOrgIdValid,
isSaving,
isValid,
onBack,
onFooterChange,
setupPhase,
]);
const handleContinueToAccess = () => {
setApiError(null);
if (!isOrgIdValid) {
setError("gcpOrgId", {
message: gcpOrgId.trim()
? "Must be a numeric Google Cloud organization ID (e.g., 123456789012)"
: "Organization ID is required",
});
return;
}
setSetupPhase(ORG_SETUP_PHASE.ACCESS);
};
const handleSaveNameOnly = async () => {
if (!organizationId) return;
setIsSaving(true);
const name = form.getValues("organizationName")?.trim() || "";
const result = await updateOrganizationName(organizationId, name);
setIsSaving(false);
if (result?.error || result?.errors) {
const errorMsg =
result.errors?.[0]?.detail ?? result.error ?? "Failed to update name";
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMsg,
});
return;
}
toast({
title: "Success!",
description: "Organization name updated successfully.",
});
onClose?.();
};
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
event.preventDefault();
if (intent === ORG_WIZARD_INTENT.EDIT_NAME) {
void handleSaveNameOnly();
return;
}
handleContinueToAccess();
return;
}
void handleSubmit((data) =>
submitOrganizationSetup({ ...data, orgType: ORGANIZATION_TYPE.GCP }),
)(event);
};
useEffect(() => {
if (!apiError) return;
document
.getElementById(formId)
?.scrollIntoView({ block: "start", behavior: "smooth" });
}, [apiError, formId]);
return (
<Form {...form}>
<SecretReplaceWarningModal
warning={replaceSecretWarning}
onConfirm={confirmSecretReplace}
onCancel={cancelSecretReplace}
/>
<form
id={formId}
onSubmit={handleFormSubmit}
className="flex flex-col gap-5"
>
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<GCPProviderBadge size={32} />
<h3 className="text-base font-semibold">
Google Cloud (GCP) / Organization Details
</h3>
</div>
<p className="text-muted-foreground text-sm">
Enter the Google Cloud organization ID for the projects you want
to add to Prowler.
</p>
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && (
<div className="flex items-center gap-4">
<GCPProviderBadge size={32} />
<h3 className="text-base font-semibold">
Google Cloud (GCP) / Authentication Details
</h3>
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && isBusy && (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<Spinner className="size-6" />
<p className="text-sm font-medium">Gathering GCP Projects...</p>
</div>
</div>
)}
{apiError && (
<Alert variant="error">
<AlertDescription className="text-text-error-primary">
{apiError}
</AlertDescription>
</Alert>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
discoveryTimedOut &&
!isBusy && (
<DiscoveryTimeoutNotice
onKeepWaiting={() => void keepWaitingForDiscovery()}
onRetry={() => void retryDiscovery()}
/>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
discoveryFailed &&
!isBusy && (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => void retryDiscovery()}
>
Retry discovery
</Button>
)}
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
<div className="flex flex-col gap-4">
<WizardInputField
control={control}
name="gcpOrgId"
label="Organization ID"
labelPlacement="outside"
placeholder="e.g. 123456789012"
isRequired
isReadOnly={isReadOnlyOrgId}
isDisabled={isReadOnlyOrgId}
/>
<WizardInputField
control={control}
name="organizationName"
label="Name (optional)"
labelPlacement="outside"
placeholder=""
isRequired={false}
/>
<p className="text-muted-foreground text-sm">
If left blank, Prowler will use the organization name stored in
Google Cloud.
</p>
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isBusy && (
<div className="flex flex-col gap-6">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
Choose how Prowler authenticates to your Google Cloud
organization.
</p>
<div className="flex flex-col gap-3">
<RadioCard
title="Service Account Key"
icon={GCPProviderBadge}
selected={credentialMethod === ORG_SECRET_TYPE.SERVICE_ACCOUNT}
onClick={() =>
setValue(
"credentialMethod",
ORG_SECRET_TYPE.SERVICE_ACCOUNT,
{
shouldValidate: true,
},
)
}
/>
<RadioCard
title="Client ID, Client Secret and Refresh Token"
icon={GCPProviderBadge}
selected={credentialMethod === ORG_SECRET_TYPE.STATIC}
onClick={() =>
setValue("credentialMethod", ORG_SECRET_TYPE.STATIC, {
shouldValidate: true,
})
}
/>
</div>
{credentialMethod === ORG_SECRET_TYPE.SERVICE_ACCOUNT ? (
<WizardTextareaField
control={control}
name="serviceAccountKey"
label="Service Account Key"
labelPlacement="outside"
placeholder="Paste your Service Account Key JSON content here"
minRows={10}
isRequired
/>
) : (
<div className="flex flex-col gap-4">
<WizardInputField
control={control}
name="clientId"
label="Client ID"
labelPlacement="outside"
isRequired
/>
<WizardInputField
control={control}
name="clientSecret"
label="Client Secret"
labelPlacement="outside"
password
isRequired
/>
<WizardInputField
control={control}
name="refreshToken"
label="Refresh Token"
labelPlacement="outside"
password
isRequired
/>
</div>
)}
</div>
)}
</form>
</Form>
);
}
@@ -1,6 +1,76 @@
/**
* One error object from a JSON:API error body. `detail` is not guaranteed: the
* organization endpoints put the message under the offending field's own key and
* let the pointer stop at the containing attribute —
* `{"service_account_key": "…", "source": {"pointer": "/data/attributes/secret"}}`.
*/
export interface ApiErrorObject {
detail?: string;
source?: { pointer?: string };
code?: string;
status?: string;
[field: string]: unknown;
}
interface ErrorResult {
error?: string;
errors?: Array<{ detail?: string }>;
errors?: ApiErrorObject[];
}
/** Keys carrying error metadata rather than a field's message. */
const ERROR_META_KEYS = new Set([
"detail",
"source",
"code",
"status",
"title",
]);
function humanizeFieldName(field: string): string {
const words = field.replace(/_/g, " ");
return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
}
function findFieldMessage(
error: ApiErrorObject,
): { field: string; message: string } | null {
for (const [field, value] of Object.entries(error)) {
if (ERROR_META_KEYS.has(field)) continue;
if (typeof value === "string" && value.trim()) {
return { field, message: value.trim() };
}
}
return null;
}
/**
* Readable text for a single server error: `detail` when present, otherwise the
* field-keyed message prefixed with its field — in that shape the key is the only
* thing naming the input the message is about.
*/
export function describeApiError(error: ApiErrorObject): string | null {
if (typeof error.detail === "string" && error.detail.trim()) {
return error.detail.trim();
}
const fieldMessage = findFieldMessage(error);
if (!fieldMessage) {
return null;
}
return `${humanizeFieldName(fieldMessage.field)}: ${fieldMessage.message}`;
}
/**
* The field names an error mentions — pointer segments plus its own keys — so a
* form field matches whichever of the two shapes the error arrived in.
*/
export function apiErrorFieldNames(error: ApiErrorObject): string {
const pointer = error.source?.pointer ?? "";
const keys = Object.keys(error).filter((key) => !ERROR_META_KEYS.has(key));
return `${pointer} ${keys.join(" ")}`;
}
export function extractErrorMessage(
@@ -12,7 +82,8 @@ export function extractErrorMessage(
}
const responseRecord = response as ErrorResult;
const detailedError = responseRecord.errors?.[0]?.detail;
const firstError = responseRecord.errors?.[0];
const detailedError = firstError ? describeApiError(firstError) : null;
return detailedError || responseRecord.error || fallback;
}
@@ -0,0 +1,275 @@
import {
getSelectableCandidateIds,
getSelectableCandidateIdsForTarget,
mapAwsDiscovery,
mapGcpDiscovery,
} from "@/actions/organizations/organizations.adapter";
import {
AwsDiscoveryResult,
GcpDiscoveryResult,
OrgFlowType,
OrgHierarchy,
ORG_SECRET_TYPE,
ORGANIZATION_TYPE,
OrgSecretPayload,
} from "@/types/organizations";
interface BaseOrgSetupData {
/** Optional display name; falls back to the external id. */
organizationName?: string;
}
export interface AwsOrgSetupData extends BaseOrgSetupData {
orgType: typeof ORGANIZATION_TYPE.AWS;
/** AWS organization id (`o-…`) — the external id the organization matches on. */
awsOrgId: string;
roleArn: string;
// OU or root ID the StackSet was deployed to — scopes the default selection.
organizationalUnitId?: string;
}
export interface GcpOrgSetupData extends BaseOrgSetupData {
orgType: typeof ORGANIZATION_TYPE.GCP;
/** Numeric Google Cloud organization id — the external id matched on. */
gcpOrgId: string;
/** API secret vocabulary — `service_account` (underscore) or `static`. */
credentialMethod:
| typeof ORG_SECRET_TYPE.SERVICE_ACCOUNT
| typeof ORG_SECRET_TYPE.STATIC;
/** Service-account key pasted as JSON (validated by the form before submit). */
serviceAccountKey?: string;
clientId?: string;
clientSecret?: string;
refreshToken?: string;
}
/**
* Values collected by an organization setup form, tagged with the organization
* type that produced them: each form fills its own arm, and the strategy is
* picked from the tag, so the fields and the credentials built from them cannot
* belong to different types.
*/
export type OrgSetupSubmissionData = AwsOrgSetupData | GcpOrgSetupData;
export type OrgSetupErrorField =
| "organizationName"
| "awsOrgId"
| "gcpOrgId"
| "serviceAccountKey"
| "clientId"
| "clientSecret"
| "refreshToken";
/**
* Per-organization-type pieces of the shared setup submission chain
* (find-or-create org → create/replace secret → discover → poll → select).
* Shared machinery (ordering, polling, cancellation) stays in the hook; only
* these type-specific bits are dispatched on the discriminant.
*/
interface OrgSetupStrategy<D extends OrgSetupSubmissionData> {
orgType: D["orgType"];
/** Form field the organization external-id error attaches to. */
externalIdField: OrgSetupErrorField;
/** External id used to match/create the organization. */
getExternalId: (data: D) => string;
/** Display name to store (falls back to the external id). */
getResolvedName: (data: D) => string;
/**
* Credential payload for the organization secret. `stackSetExternalId` is the
* tenant id AWS trusts as `sts:ExternalId` — not `getExternalId`'s
* organization external id.
*/
buildSecretPayload: (data: D, stackSetExternalId: string) => OrgSecretPayload;
/**
* Maps a secret-scoped server error to the form field it belongs to, or null to
* surface it in the banner. Matched on names rather than the pointer, which may
* stop at `/data/attributes/secret` and leave the field as the error's own key.
*/
mapSecretErrorField: (fieldNames: string) => OrgSetupErrorField | null;
/**
* Normalize the raw discovery result into the common hierarchy model and pick
* the candidates to pre-select.
*/
ingestDiscovery: (
rawResult: unknown,
data: D,
) => { hierarchy: OrgHierarchy; defaultSelection: string[] };
/** Copy shown when discovery reports/looks like an auth failure. */
authFailureMessage: (detail?: string) => string;
}
/**
* Human copy for the machine codes a failed discovery reports in
* `attributes.error`. The code decides the framing too: only some of them are
* credential problems, so "Authentication failed…" is wrong for the rest.
*/
const DISCOVERY_ERROR_COPY: Record<string, string> = {
gcp_invalid_organization_id:
"That organization ID is not valid. Copy the numeric ID from the Google Cloud console and try again.",
gcp_organization_not_found:
"No organization with that ID was found. Check the ID, and that the service account has been granted access to the organization.",
gcp_insufficient_permissions:
"The service account cannot list this organization's folders and projects. Grant it the Folder Viewer and Project Viewer roles at the organization level, then try again.",
gcp_service_unavailable:
"Google Cloud did not respond while reading the organization. Nothing is wrong with your credentials — try again in a few minutes.",
hierarchy_depth_exceeded:
"This organization's folder hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it.",
};
/**
* Copy for a failed discovery. An unknown code falls back to the type's
* auth-failure copy without the raw token, which is a support detail.
*/
function describeDiscoveryFailure(
code: string | undefined,
authFailure: string,
): string {
const trimmedCode = code?.trim();
if (!trimmedCode) {
return authFailure;
}
return DISCOVERY_ERROR_COPY[trimmedCode] ?? authFailure;
}
/**
* A strategy with its submission data already applied, so the hook never holds a
* strategy and a data object it could pair with the wrong type.
*/
export interface BoundOrgSetupStrategy {
orgType: OrgFlowType;
externalIdField: OrgSetupErrorField;
/** External id used to match/create the organization. */
externalId: string;
resolvedName: string;
buildSecretPayload: (stackSetExternalId: string) => OrgSecretPayload;
mapSecretErrorField: (fieldNames: string) => OrgSetupErrorField | null;
ingestDiscovery: (rawResult: unknown) => {
hierarchy: OrgHierarchy;
defaultSelection: string[];
};
authFailureMessage: (detail?: string) => string;
/** Copy for a discovery that failed with a machine error code. */
discoveryFailureMessage: (code?: string) => string;
}
const AWS_AUTH_FAILURE =
"Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.";
const awsOrgSetupStrategy: OrgSetupStrategy<AwsOrgSetupData> = {
orgType: ORGANIZATION_TYPE.AWS,
externalIdField: "awsOrgId",
getExternalId: (data) => data.awsOrgId,
getResolvedName: (data) => data.organizationName?.trim() || data.awsOrgId,
buildSecretPayload: (data, stackSetExternalId) => ({
secretType: ORG_SECRET_TYPE.ROLE,
secret: {
role_arn: data.roleArn,
external_id: stackSetExternalId,
},
}),
// AWS role-secret field errors surface in the banner (no dedicated fields).
mapSecretErrorField: () => null,
ingestDiscovery: (rawResult, data) => {
const hierarchy = mapAwsDiscovery(rawResult as AwsDiscoveryResult);
// The deployment (management/delegated admin) account is where the local
// role is created; its ID is the one embedded in the Role ARN.
const deploymentCandidateId = data.roleArn.match(
/^arn:aws:iam::(\d{12}):role\//,
)?.[1];
return {
hierarchy,
defaultSelection: getSelectableCandidateIdsForTarget(
hierarchy,
data.organizationalUnitId ?? "",
deploymentCandidateId,
),
};
},
authFailureMessage: (detail) =>
detail ? `${AWS_AUTH_FAILURE} ${detail}` : AWS_AUTH_FAILURE,
};
const GCP_AUTH_FAILURE =
"Authentication failed. Please verify the service account permissions or credentials, then try again.";
const gcpOrgSetupStrategy: OrgSetupStrategy<GcpOrgSetupData> = {
orgType: ORGANIZATION_TYPE.GCP,
externalIdField: "gcpOrgId",
getExternalId: (data) => data.gcpOrgId.trim(),
getResolvedName: (data) =>
data.organizationName?.trim() || data.gcpOrgId.trim(),
buildSecretPayload: (data) => {
if (data.credentialMethod === ORG_SECRET_TYPE.STATIC) {
return {
secretType: ORG_SECRET_TYPE.STATIC,
secret: {
client_id: data.clientId?.trim() ?? "",
client_secret: data.clientSecret?.trim() ?? "",
refresh_token: data.refreshToken?.trim() ?? "",
},
};
}
// The form validates this JSON before submit, so the parse cannot throw here.
return {
secretType: ORG_SECRET_TYPE.SERVICE_ACCOUNT,
secret: {
service_account_key: JSON.parse(data.serviceAccountKey ?? "{}"),
},
};
},
mapSecretErrorField: (fieldNames) => {
if (fieldNames.includes("service_account_key")) return "serviceAccountKey";
if (fieldNames.includes("client_id")) return "clientId";
if (fieldNames.includes("client_secret")) return "clientSecret";
if (fieldNames.includes("refresh_token")) return "refreshToken";
return null;
},
ingestDiscovery: (rawResult) => {
const hierarchy = mapGcpDiscovery(rawResult as GcpDiscoveryResult);
// GCP has no StackSet-style target scoping, so the default is every ready
// project; folder ancestors are derived server-side.
return {
hierarchy,
defaultSelection: getSelectableCandidateIds(hierarchy),
};
},
authFailureMessage: (detail) =>
detail ? `${GCP_AUTH_FAILURE} ${detail}` : GCP_AUTH_FAILURE,
};
function bind<D extends OrgSetupSubmissionData>(
strategy: OrgSetupStrategy<D>,
data: D,
): BoundOrgSetupStrategy {
return {
orgType: strategy.orgType,
externalIdField: strategy.externalIdField,
externalId: strategy.getExternalId(data),
resolvedName: strategy.getResolvedName(data),
buildSecretPayload: (stackSetExternalId) =>
strategy.buildSecretPayload(data, stackSetExternalId),
mapSecretErrorField: strategy.mapSecretErrorField,
ingestDiscovery: (rawResult) => strategy.ingestDiscovery(rawResult, data),
authFailureMessage: strategy.authFailureMessage,
discoveryFailureMessage: (code) =>
describeDiscoveryFailure(code, strategy.authFailureMessage()),
};
}
/**
* Binds the submission data to the strategy its own tag names. The switch has no
* default, so a new organization type is a compile error until it brings one.
*/
export function bindOrgSetupStrategy(
data: OrgSetupSubmissionData,
): BoundOrgSetupStrategy {
switch (data.orgType) {
case ORGANIZATION_TYPE.AWS:
return bind(awsOrgSetupStrategy, data);
case ORGANIZATION_TYPE.GCP:
return bind(gcpOrgSetupStrategy, data);
}
}
@@ -1,18 +1,26 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
import { useOrgSetupStore } from "@/store/organizations/store";
import { APPLY_STATUS } from "@/types/organizations";
import {
CONNECTION_TEST_STATUS,
type GcpOrgHierarchy,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import { useOrgAccountSelectionFlow } from "./use-org-account-selection-flow";
const organizationsActionsMock = vi.hoisted(() => ({
applyDiscovery: vi.fn(),
}));
const providersActionsMock = vi.hoisted(() => ({
checkConnectionProvider: vi.fn(),
getProvider: vi.fn(),
getProviderUidsByIds: vi.fn(),
revalidateProviders: vi.fn(),
startProviderConnectionChecks: vi.fn(),
}));
const tasksActionsMock = vi.hoisted(() => ({
getTasksByIds: vi.fn(),
}));
vi.mock(
@@ -20,81 +28,56 @@ vi.mock(
() => organizationsActionsMock,
);
vi.mock("@/actions/providers/providers", () => providersActionsMock);
vi.mock("@/actions/task/tasks", () => tasksActionsMock);
const TEST_ACCOUNTS = ["111111111111", "222222222222"] as const;
const ORGANIZATION_UID = "organizations/123456789012";
const PROJECT_UID = "projects/acme-prod";
const PROVIDER_ID = "provider-1";
function setupDiscoveryAndSelection(
selectedAccountIds: string[] = [TEST_ACCOUNTS[0]],
) {
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Organization", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("discovery-1", {
roots: [{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] }],
organizational_units: [],
accounts: [
{
id: TEST_ACCOUNTS[0],
name: "Account One",
arn: `arn:aws:organizations::${TEST_ACCOUNTS[0]}:account/o-123/${TEST_ACCOUNTS[0]}`,
email: "one@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: TEST_ACCOUNTS[1],
name: "Account Two",
arn: `arn:aws:organizations::${TEST_ACCOUNTS[1]}:account/o-123/${TEST_ACCOUNTS[1]}`,
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
],
});
useOrgSetupStore.getState().setSelectedAccountIds(selectedAccountIds);
const GCP_HIERARCHY: GcpOrgHierarchy = {
orgType: ORGANIZATION_TYPE.GCP,
organization: { uid: ORGANIZATION_UID, name: "Acme" },
nodes: [],
candidates: [
{ uid: PROJECT_UID, label: "Acme Prod", parentId: ORGANIZATION_UID },
],
};
/** Seeds the store as the discovery step leaves it, with the project selected. */
function seedAppliedSelection() {
const store = useOrgSetupStore.getState();
store.setOrganizationType(ORGANIZATION_TYPE.GCP);
store.setOrganization("org-1", "Acme", ORGANIZATION_UID);
store.setDiscovery("discovery-1", GCP_HIERARCHY);
store.setSelectedCandidateIds([PROJECT_UID]);
}
function buildApplySuccessResult(accountIds: string[]) {
const accountProviderMappings = accountIds.map((accountId, index) => ({
account_id: accountId,
provider_id: `provider-${String.fromCharCode(97 + index)}`,
}));
const providers = accountProviderMappings.map((mapping) => ({
id: mapping.provider_id,
}));
interface RenderedFlow {
onNext: ReturnType<typeof vi.fn>;
startTesting: () => Promise<void>;
}
function renderFlow(): RenderedFlow {
const onNext = vi.fn();
let footerConfig: WizardFooterConfig | null = null;
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext,
onSkip: vi.fn(),
onFooterChange: (config) => {
footerConfig = config;
},
}),
);
return {
data: {
attributes: {
account_provider_mappings: accountProviderMappings,
},
relationships: {
providers: {
data: providers,
},
},
onNext,
startTesting: async () => {
await act(async () => {
footerConfig?.onAction?.();
});
},
};
}
@@ -104,229 +87,74 @@ describe("useOrgAccountSelectionFlow", () => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
organizationsActionsMock.applyDiscovery.mockReset();
providersActionsMock.checkConnectionProvider.mockReset();
providersActionsMock.getProvider.mockReset();
setupDiscoveryAndSelection();
});
for (const mockFn of [
...Object.values(organizationsActionsMock),
...Object.values(providersActionsMock),
...Object.values(tasksActionsMock),
]) {
mockFn.mockReset();
}
it("applies selected accounts, tests all connections, and advances on full success", async () => {
// Given
organizationsActionsMock.applyDiscovery.mockResolvedValue(
buildApplySuccessResult([TEST_ACCOUNTS[0]]),
);
providersActionsMock.checkConnectionProvider.mockResolvedValue({
data: {},
});
const onNext = vi.fn();
const onFooterChange = vi.fn();
let latestFooterConfig: {
onAction?: () => void;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext,
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.onAction).toBeDefined();
});
act(() => {
latestFooterConfig?.onAction?.();
});
// Then
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledWith(
"org-1",
"discovery-1",
[{ id: TEST_ACCOUNTS[0] }],
[],
);
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});
it("retests only failed providers when retrying without changing account selection", async () => {
// Given
setupDiscoveryAndSelection([...TEST_ACCOUNTS]);
organizationsActionsMock.applyDiscovery.mockResolvedValue(
buildApplySuccessResult([...TEST_ACCOUNTS]),
);
const testedProviderIds: string[] = [];
const providerAttempts: Record<string, number> = {};
providersActionsMock.checkConnectionProvider.mockImplementation(
async (formData: FormData) => {
const providerId = String(formData.get("providerId"));
testedProviderIds.push(providerId);
providerAttempts[providerId] = (providerAttempts[providerId] ?? 0) + 1;
if (providerId === "provider-a" && providerAttempts[providerId] === 1) {
return { error: "Connection failed." };
}
return { data: {} };
},
);
const onNext = vi.fn();
const onFooterChange = vi.fn();
let latestFooterConfig: {
onAction?: () => void;
actionDisabled?: boolean;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext,
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.onAction).toBeDefined();
expect(latestFooterConfig?.actionDisabled).toBe(false);
});
act(() => {
latestFooterConfig?.onAction?.();
});
await waitFor(() => {
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(2);
});
act(() => {
latestFooterConfig?.onAction?.();
});
// Then
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledTimes(1);
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(3);
expect(onNext).toHaveBeenCalledTimes(1);
});
expect(testedProviderIds.filter((id) => id === "provider-a")).toHaveLength(
2,
);
expect(testedProviderIds.filter((id) => id === "provider-b")).toHaveLength(
1,
);
});
it("keeps Test Connections action visible after reselection in testing view", async () => {
// Given
organizationsActionsMock.applyDiscovery.mockResolvedValue({
errors: [{ detail: "Apply failed." }],
data: {
relationships: { providers: { data: [{ id: PROVIDER_ID }] } },
},
});
const onFooterChange = vi.fn();
let latestFooterConfig: {
showAction?: boolean;
actionDisabled?: boolean;
onAction?: () => void;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
const { result } = renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext: vi.fn(),
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.showAction).toBe(true);
expect(latestFooterConfig?.onAction).toBeDefined();
});
act(() => {
latestFooterConfig?.onAction?.();
});
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.handleTreeSelectionChange(["222222222222"]);
});
// Then
await waitFor(() => {
expect(latestFooterConfig?.showAction).toBe(true);
expect(latestFooterConfig?.actionDisabled).toBe(false);
expect(latestFooterConfig?.onAction).toBeDefined();
providersActionsMock.getProviderUidsByIds.mockResolvedValue({
[PROVIDER_ID]: PROJECT_UID,
});
providersActionsMock.revalidateProviders.mockResolvedValue(undefined);
});
it("uses latest selected accounts when applying discovery", async () => {
// Given
setupDiscoveryAndSelection([TEST_ACCOUNTS[0]]);
organizationsActionsMock.applyDiscovery.mockResolvedValue(
buildApplySuccessResult([TEST_ACCOUNTS[1]]),
);
providersActionsMock.checkConnectionProvider.mockResolvedValue({
data: {},
});
const onFooterChange = vi.fn();
let latestFooterConfig: {
onAction?: () => void;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
describe("connection test outcomes", () => {
it("fails a provider whose check was dispatched without a task id", async () => {
// Given a 2xx dispatch that carried no task, so nothing was ever tested.
seedAppliedSelection();
providersActionsMock.startProviderConnectionChecks.mockResolvedValue({
[PROVIDER_ID]: {},
});
const { onNext, startTesting } = renderFlow();
// When
await startTesting();
// Then
await waitFor(() => {
expect(useOrgSetupStore.getState().connectionResults[PROVIDER_ID]).toBe(
CONNECTION_TEST_STATUS.ERROR,
);
});
expect(
useOrgSetupStore.getState().connectionErrors[PROVIDER_ID],
).toBeTruthy();
expect(onNext).not.toHaveBeenCalled();
});
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext: vi.fn(),
onSkip: vi.fn(),
onFooterChange,
}),
);
it("advances once every dispatched task reports a connection", async () => {
// Given
seedAppliedSelection();
providersActionsMock.startProviderConnectionChecks.mockResolvedValue({
[PROVIDER_ID]: { taskId: "task-1" },
});
tasksActionsMock.getTasksByIds.mockResolvedValue({
"task-1": {
data: {
attributes: { state: "completed", result: { connected: true } },
},
},
});
const { onNext, startTesting } = renderFlow();
// When
act(() => {
useOrgSetupStore.getState().setSelectedAccountIds([TEST_ACCOUNTS[1]]);
});
await waitFor(() => {
expect(latestFooterConfig?.onAction).toBeDefined();
});
act(() => {
latestFooterConfig?.onAction?.();
});
// When
await startTesting();
// Then
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledWith(
"org-1",
"discovery-1",
[{ id: TEST_ACCOUNTS[1] }],
[],
);
// Then
await waitFor(() => {
expect(useOrgSetupStore.getState().connectionResults[PROVIDER_ID]).toBe(
CONNECTION_TEST_STATUS.SUCCESS,
);
});
expect(onNext).toHaveBeenCalledTimes(1);
});
});
});
@@ -3,10 +3,11 @@
import { useEffect, useRef, useState } from "react";
import { applyDiscovery } from "@/actions/organizations/organizations";
import { getOuIdsForSelectedAccounts } from "@/actions/organizations/organizations.adapter";
import { buildApplyPayload } from "@/actions/organizations/organizations.adapter";
import {
checkConnectionProvider,
getProvider,
getProviderUidsByIds,
revalidateProviders,
startProviderConnectionChecks,
} from "@/actions/providers/providers";
import {
WIZARD_FOOTER_ACTION_TYPE,
@@ -16,15 +17,15 @@ import { useOrgSetupStore } from "@/store/organizations/store";
import {
CONNECTION_TEST_STATUS,
ConnectionTestStatus,
PROVIDER_SECRET_STATE,
} from "@/types/organizations";
import { TREE_ITEM_STATUS, TreeDataItem } from "@/types/tree";
import {
buildAccountToProviderMap,
buildCandidateToProviderMap,
canAdvanceToLaunchStep,
getLaunchableProviderIds,
pollConnectionTask,
runWithConcurrencyLimit,
pollConnectionTasks,
} from "../org-account-selection.utils";
import { extractErrorMessage } from "./error-utils";
@@ -106,6 +107,7 @@ function buildTreeWithConnectionState(
connectionResults: Record<string, ConnectionTestStatus>,
connectionErrors: Record<string, string>,
showPendingState: boolean,
hasAppliedProviders: boolean,
): TreeDataItem[] {
return nodes.map((node) => {
const children = node.children
@@ -116,6 +118,7 @@ function buildTreeWithConnectionState(
connectionResults,
connectionErrors,
showPendingState,
hasAppliedProviders,
)
: undefined;
@@ -145,6 +148,13 @@ function buildTreeWithConnectionState(
isLoading = true;
status = undefined;
errorMessage = undefined;
} else if (hasAppliedProviders) {
// Applied, but no outcome ever arrived for this account — typically an
// unresolved provider uid. Without this the row falls back to a plain
// checked box and reads as if the test had passed.
isLoading = false;
status = TREE_ITEM_STATUS.ERROR;
errorMessage = "Connection result unavailable for this account.";
}
}
@@ -179,18 +189,18 @@ export function useOrgAccountSelectionFlow({
organizationId,
organizationExternalId,
discoveryId,
discoveryResult,
hierarchy,
treeData,
accountLookup,
selectableAccountIds,
selectableAccountIdSet,
selectedAccountIds,
accountAliases,
candidateLookup,
selectableCandidateIds,
selectableCandidateIdSet,
selectedCandidateIds,
candidateAliases,
createdProviderIds,
connectionResults,
connectionErrors,
setSelectedAccountIds,
setAccountAlias,
setSelectedCandidateIds,
setCandidateAlias,
setCreatedProviderIds,
clearValidationState,
setConnectionError,
@@ -201,7 +211,13 @@ export function useOrgAccountSelectionFlow({
const [isApplying, setIsApplying] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [accountToProviderMap, setAccountToProviderMap] = useState<
// Apply overwrites the credentials of already-onboarded providers whose
// registration is `will_replace`, so it is confirmed first.
const [replaceWarning, setReplaceWarning] = useState<{
names: string[];
} | null>(null);
const replaceConfirmedRef = useRef(false);
const [candidateToProviderMap, setCandidateToProviderMap] = useState<
Map<string, string>
>(new Map());
const isMountedRef = useRef(true);
@@ -210,21 +226,30 @@ export function useOrgAccountSelectionFlow({
const lastAppliedSelectionKeyRef = useRef<string>("");
const startTestingActionRef = useRef<() => void>(() => {});
const sanitizedSelectedAccountIds = selectedAccountIds.filter((id) =>
selectableAccountIdSet.has(id),
const sanitizedSelectedCandidateIds = selectedCandidateIds.filter((id) =>
selectableCandidateIdSet.has(id),
);
const selectedAccountKey = getSelectionKey(sanitizedSelectedAccountIds);
const selectedCandidateKey = getSelectionKey(sanitizedSelectedCandidateIds);
const selectedIdsForTree = buildTreeSelectedIds(
treeData,
sanitizedSelectedAccountIds,
selectableAccountIdSet,
sanitizedSelectedCandidateIds,
selectableCandidateIdSet,
);
const selectedAccountIdSet = new Set(sanitizedSelectedAccountIds);
const selectedCount = sanitizedSelectedAccountIds.length;
const totalAccounts = selectableAccountIds.length;
const selectedCandidateIdSet = new Set(sanitizedSelectedCandidateIds);
const selectedCount = sanitizedSelectedCandidateIds.length;
const totalCandidates = selectableCandidateIds.length;
const hasConnectionErrors = Object.values(connectionResults).some(
(status) => status === CONNECTION_TEST_STATUS.ERROR,
);
const willReplaceSelectedNames = sanitizedSelectedCandidateIds
.map((id) => candidateLookup.get(id))
.filter(
(candidate) =>
candidate?.registration?.provider_secret_state ===
PROVIDER_SECRET_STATE.WILL_REPLACE,
)
.map((candidate) => candidate?.label || candidate?.uid || "")
.filter((name) => name.length > 0);
const launchableProviderIds = getLaunchableProviderIds(
createdProviderIds,
connectionResults,
@@ -238,11 +263,12 @@ export function useOrgAccountSelectionFlow({
const treeDataWithConnectionState = isTestingView
? buildTreeWithConnectionState(
treeData,
selectedAccountIdSet,
accountToProviderMap,
selectedCandidateIdSet,
candidateToProviderMap,
connectionResults,
connectionErrors,
isApplying || isTesting,
createdProviderIds.length > 0,
)
: treeData;
@@ -268,64 +294,86 @@ export function useOrgAccountSelectionFlow({
setConnectionError(id, null);
}
const settleProvider = (
providerId: string,
result: { success: boolean; error?: string },
) => {
if (!isMountedRef.current || signal.aborted) {
return;
}
setConnectionResult(
providerId,
result.success
? CONNECTION_TEST_STATUS.SUCCESS
: CONNECTION_TEST_STATUS.ERROR,
);
setConnectionError(
providerId,
result.success
? null
: result.error || "Connection failed for this account.",
);
};
try {
await runWithConcurrencyLimit(providerIds, 5, async (providerId) => {
if (!isMountedRef.current || signal.aborted) {
return;
}
// One action dispatches every check and one reads every pending task per
// round: Next runs client-invoked server actions one at a time, so a loop
// here would serialize the batch whatever concurrency it asked for.
const outcomes = await startProviderConnectionChecks(providerIds);
if (!isMountedRef.current || signal.aborted) {
return;
}
try {
const formData = new FormData();
formData.set("providerId", providerId);
const providerIdByTaskId = new Map<string, string>();
const checkResult = await checkConnectionProvider(formData);
if (!isMountedRef.current || signal.aborted) {
return;
}
for (const providerId of providerIds) {
const outcome = outcomes[providerId];
if (checkResult?.error || checkResult?.errors?.length) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
setConnectionError(
providerId,
extractErrorMessage(checkResult, "Connection test failed."),
);
return;
}
const taskId = checkResult?.data?.id;
if (!taskId) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.SUCCESS);
setConnectionError(providerId, null);
return;
}
const taskResult = await pollConnectionTask(taskId, { signal });
if (!isMountedRef.current || signal.aborted) {
return;
}
setConnectionResult(
providerId,
taskResult.success
? CONNECTION_TEST_STATUS.SUCCESS
: CONNECTION_TEST_STATUS.ERROR,
);
setConnectionError(
providerId,
taskResult.success
? null
: taskResult.error || "Connection failed for this account.",
);
} catch {
if (!isMountedRef.current || signal.aborted) {
return;
}
if (!outcome || outcome.error) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
setConnectionError(
providerId,
"Unexpected error during connection test.",
extractErrorMessage(outcome?.error, "Connection test failed."),
);
continue;
}
// No task id means no check ever ran, so it cannot count as passing.
if (!outcome.taskId) {
settleProvider(providerId, {
success: false,
error: "Connection test did not start.",
});
continue;
}
providerIdByTaskId.set(outcome.taskId, providerId);
}
await pollConnectionTasks(Array.from(providerIdByTaskId.keys()), {
signal,
onSettled: (taskId, result) => {
const providerId = providerIdByTaskId.get(taskId);
if (providerId) {
settleProvider(providerId, result);
}
},
});
} catch {
if (isMountedRef.current && !signal.aborted) {
for (const providerId of providerIds) {
if (
useOrgSetupStore.getState().connectionResults[providerId] ===
CONNECTION_TEST_STATUS.PENDING
) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
setConnectionError(
providerId,
"Unexpected error during connection test.",
);
}
}
}
} finally {
if (connectionTestAbortControllerRef.current === abortController) {
connectionTestAbortControllerRef.current = null;
@@ -339,6 +387,9 @@ export function useOrgAccountSelectionFlow({
return;
}
// Once for the whole batch: the checks themselves revalidate nothing.
void revalidateProviders();
const latestResults = useOrgSetupStore.getState().connectionResults;
const allPassed =
providerIds.length > 0 &&
@@ -353,34 +404,28 @@ export function useOrgAccountSelectionFlow({
};
const handleApplyAndTest = async () => {
if (!organizationId || !discoveryId || !discoveryResult) {
if (!organizationId || !discoveryId || !hierarchy) {
return;
}
setApplyError(null);
setIsApplying(true);
const currentSelectedAccountIds = useOrgSetupStore
const currentSelectedCandidateIds = useOrgSetupStore
.getState()
.selectedAccountIds.filter((id) => selectableAccountIdSet.has(id));
const currentSelectionKey = getSelectionKey(currentSelectedAccountIds);
.selectedCandidateIds.filter((id) => selectableCandidateIdSet.has(id));
const currentSelectionKey = getSelectionKey(currentSelectedCandidateIds);
const accounts = currentSelectedAccountIds.map((id) => ({
id,
...(accountAliases[id] ? { alias: accountAliases[id] } : {}),
}));
const ouIds = getOuIdsForSelectedAccounts(
discoveryResult,
currentSelectedAccountIds,
// Per-type apply payload, discriminated by the hierarchy being applied: AWS
// derives OU ancestors client-side; GCP sends projects only (folder
// ancestors are derived server-side).
const payload = buildApplyPayload(
hierarchy,
currentSelectedCandidateIds,
candidateAliases,
);
const organizationalUnits = ouIds.map((id) => ({ id }));
const result = await applyDiscovery(
organizationId,
discoveryId,
accounts,
organizationalUnits,
);
const result = await applyDiscovery(organizationId, discoveryId, payload);
if (!isMountedRef.current) {
return;
}
@@ -398,29 +443,16 @@ export function useOrgAccountSelectionFlow({
) ?? [];
setCreatedProviderIds(providerIds);
const mapping = await buildAccountToProviderMap({
selectedAccountIds: currentSelectedAccountIds,
const mapping = await buildCandidateToProviderMap({
selectedCandidateIds: currentSelectedCandidateIds,
providerIds,
applyResult: result,
resolveProviderUidById: async (providerId) => {
const providerFormData = new FormData();
providerFormData.set("id", providerId);
const providerResponse = await getProvider(providerFormData);
if (providerResponse?.error || providerResponse?.errors?.length) {
return null;
}
return typeof providerResponse?.data?.attributes?.uid === "string"
? providerResponse.data.attributes.uid
: null;
},
resolveProviderUids: getProviderUidsByIds,
});
if (!isMountedRef.current) {
return;
}
setAccountToProviderMap(mapping);
setCandidateToProviderMap(mapping);
setIsApplying(false);
lastAppliedSelectionKeyRef.current = currentSelectionKey;
@@ -438,9 +470,13 @@ export function useOrgAccountSelectionFlow({
const shouldApplySelection =
!hasAppliedRef.current ||
lastAppliedSelectionKeyRef.current !== selectedAccountKey;
lastAppliedSelectionKeyRef.current !== selectedCandidateKey;
if (shouldApplySelection) {
if (willReplaceSelectedNames.length > 0 && !replaceConfirmedRef.current) {
setReplaceWarning({ names: willReplaceSelectedNames });
return;
}
hasAppliedRef.current = true;
void handleApplyAndTest();
return;
@@ -520,26 +556,38 @@ export function useOrgAccountSelectionFlow({
]);
const handleTreeSelectionChange = (ids: string[]) => {
const filteredIds = ids.filter((id) => selectableAccountIdSet.has(id));
const nextSelectedAccountKey = getSelectionKey(filteredIds);
const filteredIds = ids.filter((id) => selectableCandidateIdSet.has(id));
const nextSelectedCandidateKey = getSelectionKey(filteredIds);
if (nextSelectedAccountKey !== selectedAccountKey) {
if (nextSelectedCandidateKey !== selectedCandidateKey) {
hasAppliedRef.current = false;
lastAppliedSelectionKeyRef.current = "";
replaceConfirmedRef.current = false;
setApplyError(null);
setAccountToProviderMap(new Map());
setCandidateToProviderMap(new Map());
clearValidationState();
}
setSelectedAccountIds(filteredIds);
setSelectedCandidateIds(filteredIds);
};
const confirmReplaceAndApply = () => {
replaceConfirmedRef.current = true;
setReplaceWarning(null);
startTestingActionRef.current();
};
const cancelReplace = () => {
setReplaceWarning(null);
setIsTestingView(false);
};
return {
accountAliases,
accountLookup,
candidateAliases,
candidateLookup,
applyError,
canAdvanceToLaunch,
discoveryResult,
hierarchy,
handleTreeSelectionChange,
hasConnectionErrors,
isTesting,
@@ -548,9 +596,12 @@ export function useOrgAccountSelectionFlow({
organizationExternalId,
selectedCount,
selectedIdsForTree,
setAccountAlias,
setCandidateAlias,
showHeaderHelperText,
totalAccounts,
totalCandidates,
treeDataWithConnectionState,
replaceWarning,
confirmReplaceAndApply,
cancelReplace,
};
}
@@ -3,7 +3,12 @@ import { createElement, type PropsWithChildren, StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { APPLY_STATUS, DISCOVERY_STATUS } from "@/types/organizations";
import {
APPLY_STATUS,
DISCOVERY_STATUS,
ORG_SECRET_TYPE,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import { useOrgSetupSubmission } from "./use-org-setup-submission";
@@ -17,6 +22,59 @@ const organizationsActionsMock = vi.hoisted(() => ({
updateOrganizationSecret: vi.fn(),
}));
const AWS_DISCOVERY_RESULT = {
roots: [{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] }],
organizational_units: [],
accounts: [
{
id: "111111111111",
name: "Account One",
arn: "arn:aws:organizations::111111111111:account/o-123/111111111111",
email: "one@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organization_node_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: "ready",
blocked_reasons: [],
},
},
],
};
const GCP_STATIC_SUBMIT_DATA = {
orgType: ORGANIZATION_TYPE.GCP,
gcpOrgId: "123456789012",
credentialMethod: ORG_SECRET_TYPE.STATIC,
clientId: "client-id",
clientSecret: "client-secret",
refreshToken: "refresh-token",
} as const;
function mockFreshSetupChain() {
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
data: { id: "org-1" },
});
organizationsActionsMock.listOrganizationSecretsByOrganizationId.mockResolvedValue(
{ data: [] },
);
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
data: { id: "secret-1" },
});
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
data: { id: "discovery-1" },
});
}
vi.mock(
"@/actions/organizations/organizations",
() => organizationsActionsMock,
@@ -26,6 +84,35 @@ function StrictModeWrapper({ children }: PropsWithChildren) {
return createElement(StrictMode, null, children);
}
/** Mocks the chain up to (and including) triggering discovery, all succeeding. */
function mockChainThroughDiscoveryTrigger() {
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
data: { id: "org-1" },
});
organizationsActionsMock.listOrganizationSecretsByOrganizationId.mockResolvedValue(
{ data: [] },
);
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
data: { id: "secret-1" },
});
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
data: { id: "discovery-1" },
});
}
const AWS_SETUP_DATA = {
orgType: ORGANIZATION_TYPE.AWS,
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
} as const;
const UNEXPECTED_DISCOVERY_RESULT =
"The organization was authenticated, but its discovery result could not be read. Please try again.";
describe("useOrgSetupSubmission", () => {
beforeEach(() => {
sessionStorage.clear();
@@ -36,10 +123,11 @@ describe("useOrgSetupSubmission", () => {
}
});
it("completes the setup chain and stores selectable accounts", async () => {
it("completes the setup chain and stores selectable candidates", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
// `true` = the form owns the field and rendered the error on it.
const setFieldError = vi.fn(() => true);
const discoveryResult = {
roots: [
{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] },
@@ -59,7 +147,7 @@ describe("useOrgSetupSubmission", () => {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
organization_node_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
@@ -78,7 +166,7 @@ describe("useOrgSetupSubmission", () => {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
organization_node_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["Already linked"],
@@ -126,6 +214,7 @@ describe("useOrgSetupSubmission", () => {
// When
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
@@ -140,14 +229,256 @@ describe("useOrgSetupSubmission", () => {
expect(state.organizationId).toBe("org-1");
expect(state.organizationExternalId).toBe("o-abc123def4");
expect(state.discoveryId).toBe("discovery-1");
expect(state.selectedAccountIds).toEqual(["111111111111"]);
expect(state.selectableAccountIds).toEqual(["111111111111"]);
expect(state.selectedCandidateIds).toEqual(["111111111111"]);
expect(state.selectableCandidateIds).toEqual(["111111111111"]);
});
it("times out then resumes the same discovery via keep waiting", async () => {
// Given — a discovery that stays running until the client budget is spent.
vi.useFakeTimers();
const onNext = vi.fn();
const setFieldError = vi.fn(() => true);
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: { attributes: { status: DISCOVERY_STATUS.RUNNING, result: {} } },
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
);
// When — polling exhausts its 60 × 3s budget.
let submitPromise: Promise<void>;
await act(async () => {
submitPromise = result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(3000 * 61);
await submitPromise;
});
// Then — the two-action timeout state is surfaced, not an error.
expect(result.current.discoveryTimedOut).toBe(true);
expect(onNext).not.toHaveBeenCalled();
// When — keep waiting and the discovery has since succeeded.
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.SUCCEEDED,
result: AWS_DISCOVERY_RESULT,
},
},
});
await act(async () => {
await result.current.keepWaitingForDiscovery();
});
// Then — resumes the SAME discovery (no new trigger) and advances.
expect(onNext).toHaveBeenCalledTimes(1);
expect(result.current.discoveryTimedOut).toBe(false);
expect(organizationsActionsMock.triggerDiscovery).toHaveBeenCalledTimes(1);
expect(useOrgSetupStore.getState().selectedCandidateIds).toEqual([
"111111111111",
]);
vi.useRealTimers();
});
it("retry triggers a fresh discovery after a failed one", async () => {
// Given — a discovery that completes as failed.
const onNext = vi.fn();
const setFieldError = vi.fn(() => true);
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValueOnce({
data: {
attributes: {
status: DISCOVERY_STATUS.FAILED,
error: "boom",
result: {},
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
);
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then — failure state with a retry affordance.
expect(result.current.discoveryFailed).toBe(true);
expect(onNext).not.toHaveBeenCalled();
// When — retry, and this time discovery succeeds.
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
data: { id: "discovery-2" },
});
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.SUCCEEDED,
result: AWS_DISCOVERY_RESULT,
},
},
});
await act(async () => {
await result.current.retryDiscovery();
});
// Then — a NEW discovery was triggered (2 total) and the flow advanced.
expect(organizationsActionsMock.triggerDiscovery).toHaveBeenCalledTimes(2);
expect(onNext).toHaveBeenCalledTimes(1);
expect(result.current.discoveryFailed).toBe(false);
});
it("keeps the retry affordance when the retry itself cannot be triggered", async () => {
// Given — a failed discovery, so the retry button is showing.
const onNext = vi.fn();
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.FAILED,
error: "boom",
result: {},
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError: vi.fn(() => true),
}),
);
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
expect(result.current.discoveryFailed).toBe(true);
// When — the retry's own trigger call fails.
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
error: "Rate limited",
});
await act(async () => {
await result.current.retryDiscovery();
});
// Then — the affordance the user just clicked is still there.
expect(result.current.apiError).toBe("Rate limited");
expect(result.current.discoveryFailed).toBe(true);
});
it("reports an unreadable discovery response without blaming credentials", async () => {
// Given — a 2xx poll response with no body, as handleApiResponse returns it.
const onNext = vi.fn();
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({ success: true });
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError: vi.fn(() => true),
}),
);
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then — the credentials were accepted, so the copy must not blame them.
expect(result.current.apiError).toContain(
"discovery result could not be read",
);
expect(result.current.apiError).not.toContain("Authentication failed");
expect(result.current.discoveryFailed).toBe(true);
expect(onNext).not.toHaveBeenCalled();
});
it("reports the chain as pending while a resumed discovery is in flight", async () => {
// Given — a discovery that times out, leaving a resume context.
const onNext = vi.fn();
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: { attributes: { status: DISCOVERY_STATUS.RUNNING, result: {} } },
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError: vi.fn(() => true),
}),
);
vi.useFakeTimers();
let submitPromise: Promise<void>;
await act(async () => {
submitPromise = result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(3000 * 61);
await submitPromise;
});
expect(result.current.discoveryTimedOut).toBe(true);
expect(result.current.isSubmissionPending).toBe(false);
// When — keep waiting, without letting the resumed poll finish.
let resumePromise: Promise<void>;
await act(async () => {
resumePromise = result.current.keepWaitingForDiscovery();
});
// Then — the forms have a flag to hang a spinner on, since react-hook-form's
// isSubmitting is false for this path.
expect(result.current.isSubmissionPending).toBe(true);
expect(result.current.discoveryTimedOut).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(3000 * 61);
await resumePromise;
});
expect(result.current.isSubmissionPending).toBe(false);
vi.useRealTimers();
});
it("maps external_id server errors to awsOrgId field errors", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
const setFieldError = vi.fn(() => true);
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
@@ -171,6 +502,7 @@ describe("useOrgSetupSubmission", () => {
// When
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.AWS,
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
@@ -190,4 +522,327 @@ describe("useOrgSetupSubmission", () => {
organizationsActionsMock.createOrganizationSecret,
).not.toHaveBeenCalled();
});
it("blames the discovery result, not the credentials, when the result cannot be mapped", async () => {
// Given — discovery succeeded, so the credentials are proven good, but the
// payload carries no root organization and the AWS mapper throws on it.
const onNext = vi.fn();
mockChainThroughDiscoveryTrigger();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.SUCCEEDED,
result: { roots: [], organizational_units: [], accounts: [] },
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError: vi.fn(),
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({ ...AWS_SETUP_DATA });
});
// Then — must not send the user off to re-check a Role ARN that works.
expect(result.current.apiError).toBe(UNEXPECTED_DISCOVERY_RESULT);
expect(result.current.apiError).not.toMatch(/Role ARN/);
expect(onNext).not.toHaveBeenCalled();
});
it("reports a discovery poll response with no payload without blaming credentials", async () => {
// Given — a 200 with no body makes the response helper return `{success: true}`,
// which used to throw while reading the status and surface as an auth failure.
const onNext = vi.fn();
mockChainThroughDiscoveryTrigger();
organizationsActionsMock.getDiscovery.mockResolvedValue({ success: true });
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError: vi.fn(),
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({ ...AWS_SETUP_DATA });
});
// Then
expect(result.current.apiError).toBe(UNEXPECTED_DISCOVERY_RESULT);
expect(onNext).not.toHaveBeenCalled();
});
it("routes a secret field error to the form when the form owns the field", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn(() => true);
mockFreshSetupChain();
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
error: "Invalid credentials",
errors: [
{
detail: "Client id is not valid.",
source: { pointer: "/data/attributes/secret/client_id" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
});
// Then — the field renders it, so the banner stays clear.
expect(setFieldError).toHaveBeenCalledWith(
"clientId",
"Client id is not valid.",
);
expect(result.current.apiError).toBeNull();
expect(onNext).not.toHaveBeenCalled();
});
it("falls back to the banner when the form does not own the field", async () => {
// Given — a form that cannot render the mapped field (reports it unhandled).
const onNext = vi.fn();
const setFieldError = vi.fn(() => false);
mockFreshSetupChain();
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
error: "Invalid credentials",
errors: [
{
detail: "Client id is not valid.",
source: { pointer: "/data/attributes/secret/client_id" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
});
// Then — the message surfaces in the banner instead of being swallowed.
expect(setFieldError).toHaveBeenCalledWith(
"clientId",
"Client id is not valid.",
);
expect(result.current.apiError).toBe("Client id is not valid.");
expect(onNext).not.toHaveBeenCalled();
});
// These endpoints' validation errors carry no `detail`: the message sits under
// the offending field's own key, and the pointer stops at the attribute above it.
it("routes a field-keyed secret error to its form field", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn(() => true);
mockFreshSetupChain();
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
error: '{"errors":[{"service_account_key":"Invalid key."}]}',
errors: [
{
service_account_key: "Invalid service account key: missing token_uri",
source: { pointer: "/data/attributes/secret" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({
orgType: ORGANIZATION_TYPE.GCP,
gcpOrgId: "123456789012",
credentialMethod: ORG_SECRET_TYPE.SERVICE_ACCOUNT,
serviceAccountKey: '{"type":"service_account"}',
});
});
// Then — field name from the error's own key, message from its value.
expect(setFieldError).toHaveBeenCalledWith(
"serviceAccountKey",
"Service account key: Invalid service account key: missing token_uri",
);
expect(result.current.apiError).toBeNull();
});
it("never paints an empty banner for a detail-less error", async () => {
// Given — nothing owns the field, so the banner is what renders it.
const onNext = vi.fn();
const setFieldError = vi.fn(() => false);
mockFreshSetupChain();
organizationsActionsMock.createOrganization.mockResolvedValue({
error: '{"errors":[{"alias":"too long"}]}',
errors: [
{
alias: "Ensure this field has no more than 100 characters.",
source: { pointer: "/data/attributes" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(AWS_SETUP_DATA);
});
// Then — readable copy, not the raw JSON body the request-level message is.
expect(result.current.apiError).toBe(
"Alias: Ensure this field has no more than 100 characters.",
);
expect(result.current.apiError).not.toContain("{");
});
it.each([
[
"gcp_invalid_organization_id",
/organization ID is not valid/,
"invalid org id",
],
[
"gcp_service_unavailable",
/Nothing is wrong with your credentials/,
"an outage",
],
[
"gcp_insufficient_permissions",
/Folder Viewer and Project Viewer/,
"missing permissions",
],
])(
"translates the %s discovery failure code into copy about %s",
async (code, expectedCopy) => {
// Given — a failed discovery reporting a machine code.
const onNext = vi.fn();
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.FAILED,
error: code,
result: {},
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext,
setFieldError: vi.fn(() => true),
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
});
// Then — human copy, and no snake_case token leaking into the banner.
expect(result.current.apiError).toMatch(expectedCopy);
expect(result.current.apiError).not.toContain(code);
expect(result.current.discoveryFailed).toBe(true);
},
);
it("does not frame a non-credential discovery failure as an auth failure", async () => {
// Given — a Google outage, with working credentials.
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.FAILED,
error: "gcp_service_unavailable",
result: {},
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext: vi.fn(),
setFieldError: vi.fn(() => true),
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
});
// Then
expect(result.current.apiError).not.toContain("Authentication failed");
});
it("keeps the auth-failure copy for an unrecognized failure code", async () => {
// Given — a code this build has no copy for; the token is a support detail.
mockFreshSetupChain();
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.FAILED,
error: "gcp_some_future_code",
result: {},
},
},
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "",
onNext: vi.fn(),
setFieldError: vi.fn(() => true),
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
});
// Then
expect(result.current.apiError).toContain("Authentication failed");
expect(result.current.apiError).not.toContain("gcp_some_future_code");
});
});
@@ -11,15 +11,32 @@ import {
triggerDiscovery,
updateOrganizationSecret,
} from "@/actions/organizations/organizations";
import { getSelectableAccountIdsForTarget } from "@/actions/organizations/organizations.adapter";
import { useOrgSetupStore } from "@/store/organizations/store";
import { DISCOVERY_STATUS, DiscoveryResult } from "@/types/organizations";
import { DISCOVERY_STATUS } from "@/types/organizations";
import { extractErrorMessage } from "./error-utils";
import {
apiErrorFieldNames,
ApiErrorObject,
describeApiError,
extractErrorMessage,
} from "./error-utils";
import {
bindOrgSetupStrategy,
BoundOrgSetupStrategy,
OrgSetupErrorField,
OrgSetupSubmissionData,
} from "./org-setup-strategy";
const DISCOVERY_POLL_INTERVAL_MS = 3000;
const DISCOVERY_MAX_RETRIES = 60;
/**
* Used once the credentials have already been accepted: from there on a response
* we cannot read must not send the user off to re-check them.
*/
const UNEXPECTED_DISCOVERY_RESULT =
"The organization was authenticated, but its discovery result could not be read. Please try again.";
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
@@ -39,27 +56,35 @@ function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
});
}
interface OrgSetupSubmissionData {
organizationName?: string;
awsOrgId: string;
roleArn: string;
// OU or root ID the StackSet was deployed to. Used to scope the default
// account selection to what was actually rolled out.
organizationalUnitId?: string;
}
interface UseOrgSetupSubmissionProps {
stackSetExternalId: string;
onNext: () => void;
setFieldError: (
field: "awsOrgId" | "organizationName",
message: string,
) => void;
setFieldError: (field: OrgSetupErrorField, message: string) => boolean;
}
interface ServerErrorResult {
error?: string;
errors?: Array<{ detail: string; source?: { pointer: string } }>;
errors?: ApiErrorObject[];
}
type PollOutcome =
| { kind: "resolved"; result: unknown }
| { kind: "failed" } // apiError already set
| { kind: "cancelled" }
| { kind: "timeout" };
interface SubmitOptions {
/** Skip the existing-secret replacement confirmation (user already agreed). */
confirmReplace?: boolean;
}
function getOrganizationProviderCount(organization: unknown): number {
const providers = (
organization as {
relationships?: { providers?: { data?: unknown[] } };
} | null
)?.relationships?.providers?.data;
return Array.isArray(providers) ? providers.length : 0;
}
export function useOrgSetupSubmission({
@@ -68,12 +93,30 @@ export function useOrgSetupSubmission({
setFieldError,
}: UseOrgSetupSubmissionProps) {
const [apiError, setApiError] = useState<string | null>(null);
// Set when setup finds an existing secret a replacement would overwrite.
const [replaceSecretWarning, setReplaceSecretWarning] = useState<{
providerCount: number;
} | null>(null);
// Set when polling exhausts its client budget while the worker keeps running.
const [discoveryTimedOut, setDiscoveryTimedOut] = useState(false);
// Set when discovery reports a failed status; the copy is already in apiError.
const [discoveryFailed, setDiscoveryFailed] = useState(false);
const [isSubmissionPending, setIsSubmissionPending] = useState(false);
const isMountedRef = useRef(true);
const pendingSubmitDataRef = useRef<OrgSetupSubmissionData | null>(null);
// Enough context to resume the same discovery, or trigger a fresh one, after a
// client-side timeout.
const resumeContextRef = useRef<{
orgId: string;
discoveryId: string;
strategy: BoundOrgSetupStrategy;
} | null>(null);
const discoveryAbortControllerRef = useRef<AbortController | null>(null);
const {
setOrganization,
setDiscoveryTriggered,
setDiscovery,
setSelectedAccountIds,
setSelectedCandidateIds,
clearValidationState,
} = useOrgSetupStore();
@@ -86,22 +129,37 @@ export function useOrgSetupSubmission({
};
}, []);
const handleServerError = (result: ServerErrorResult, context: string) => {
const handleServerError = (
result: ServerErrorResult,
context: string,
strategy: BoundOrgSetupStrategy,
) => {
if (!isMountedRef.current) {
return;
}
if (result.errors?.length) {
for (const err of result.errors) {
const pointer = err.source?.pointer ?? "";
// Both tolerate a `detail`-less error, where the error's own key names the
// field and holds the message.
const fieldNames = apiErrorFieldNames(err);
const message = describeApiError(err) ?? `Failed to create ${context}`;
if (pointer.includes("external_id") && context === "Organization") {
setFieldError("awsOrgId", err.detail);
setApiError(err.detail);
} else if (pointer.includes("name")) {
setFieldError("organizationName", err.detail);
if (fieldNames.includes("external_id") && context === "Organization") {
setFieldError(strategy.externalIdField, message);
setApiError(message);
} else if (fieldNames.includes("name")) {
if (!setFieldError("organizationName", message)) {
setApiError(message);
}
} else {
setApiError(err.detail);
const secretField =
context === "Secret"
? strategy.mapSecretErrorField(fieldNames)
: null;
if (!secretField || !setFieldError(secretField, message)) {
setApiError(message);
}
}
}
} else {
@@ -109,58 +167,104 @@ export function useOrgSetupSubmission({
}
};
// A timeout sets no apiError: the caller renders the keep-waiting/retry choice.
const pollDiscoveryResult = async (
organizationId: string,
discoveryId: string,
signal: AbortSignal,
): Promise<DiscoveryResult | null> => {
strategy: BoundOrgSetupStrategy,
): Promise<PollOutcome> => {
for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) {
if (signal.aborted || !isMountedRef.current) {
return null;
return { kind: "cancelled" };
}
const result = await getDiscovery(organizationId, discoveryId);
if (signal.aborted || !isMountedRef.current) {
return null;
return { kind: "cancelled" };
}
if (result?.error) {
setApiError(
`Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${result.error}`,
);
return null;
setApiError(strategy.authFailureMessage(result.error));
return { kind: "failed" };
}
const status = result.data.attributes.status;
const status = result?.data?.attributes?.status;
if (!status) {
setApiError(UNEXPECTED_DISCOVERY_RESULT);
return { kind: "failed" };
}
if (status === DISCOVERY_STATUS.SUCCEEDED) {
return result.data.attributes.result as DiscoveryResult;
return { kind: "resolved", result: result.data.attributes.result };
}
if (status === DISCOVERY_STATUS.FAILED) {
const backendError = result.data.attributes.error;
// `attributes.error` is a machine code, not user copy.
setApiError(
backendError
? `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${backendError}`
: "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.",
strategy.discoveryFailureMessage(result.data.attributes.error),
);
return null;
return { kind: "failed" };
}
await sleepWithAbort(DISCOVERY_POLL_INTERVAL_MS, signal);
}
if (signal.aborted || !isMountedRef.current) {
return null;
return { kind: "cancelled" };
}
setApiError(
"Authentication timed out. Please verify the credentials and try again.",
);
return null;
return { kind: "timeout" };
};
const submitOrganizationSetup = async (data: OrgSetupSubmissionData) => {
// Shared by initial submit, resume and retry.
const applyResolvedDiscovery = (
discoveryId: string,
result: unknown,
strategy: BoundOrgSetupStrategy,
) => {
const { hierarchy, defaultSelection } = strategy.ingestDiscovery(result);
setDiscovery(discoveryId, hierarchy);
setSelectedCandidateIds(defaultSelection);
onNext();
};
const handlePollOutcome = (
outcome: PollOutcome,
discoveryId: string,
strategy: BoundOrgSetupStrategy,
signal: AbortSignal,
) => {
if (signal.aborted || !isMountedRef.current) {
return;
}
if (outcome.kind === "resolved") {
// A result we cannot map is not a credentials problem.
try {
applyResolvedDiscovery(discoveryId, outcome.result, strategy);
} catch {
setApiError(UNEXPECTED_DISCOVERY_RESULT);
setDiscoveryFailed(true);
}
return;
}
if (outcome.kind === "timeout") {
setDiscoveryTimedOut(true);
return;
}
if (outcome.kind === "failed") {
setDiscoveryFailed(true);
}
};
const submitOrganizationSetup = async (
data: OrgSetupSubmissionData,
options?: SubmitOptions,
) => {
// The form's own tag picks the strategy, so the collected fields and the
// credentials/discovery built from them always belong to the same type.
const strategy = bindOrgSetupStrategy(data);
discoveryAbortControllerRef.current?.abort();
const abortController = new AbortController();
discoveryAbortControllerRef.current = abortController;
@@ -172,17 +276,22 @@ export function useOrgSetupSubmission({
}
};
let hasDiscovered = false;
try {
if (!isCancelled()) {
setApiError(null);
setDiscoveryTimedOut(false);
setDiscoveryFailed(false);
setIsSubmissionPending(true);
}
clearValidationState();
const resolvedOrganizationName =
data.organizationName?.trim() || data.awsOrgId;
const { externalId, resolvedName } = strategy;
const existingOrganizationsResult = await listOrganizationsByExternalId(
data.awsOrgId,
externalId,
strategy.orgType,
);
if (isCancelled()) {
return;
@@ -201,8 +310,8 @@ export function useOrgSetupSubmission({
id: string;
attributes?: { external_id?: string; org_type?: string };
}) =>
organization?.attributes?.external_id === data.awsOrgId &&
organization?.attributes?.org_type === "aws",
organization?.attributes?.external_id === externalId &&
organization?.attributes?.org_type === strategy.orgType,
)
: null;
@@ -210,8 +319,9 @@ export function useOrgSetupSubmission({
if (!orgId) {
const orgFormData = new FormData();
orgFormData.set("name", resolvedOrganizationName);
orgFormData.set("externalId", data.awsOrgId);
orgFormData.set("name", resolvedName);
orgFormData.set("externalId", externalId);
orgFormData.set("orgType", strategy.orgType);
const orgResult = await createOrganization(orgFormData);
if (isCancelled()) {
@@ -219,11 +329,11 @@ export function useOrgSetupSubmission({
}
if (orgResult?.error || orgResult?.errors?.length) {
handleServerError(orgResult, "Organization");
handleServerError(orgResult, "Organization", strategy);
return;
}
orgId = orgResult.data.id;
orgId = orgResult?.data?.id;
}
if (!orgId) {
@@ -234,8 +344,8 @@ export function useOrgSetupSubmission({
}
const organizationNameForStore =
existingOrganization?.attributes?.name ?? resolvedOrganizationName;
setOrganization(orgId, organizationNameForStore, data.awsOrgId);
existingOrganization?.attributes?.name ?? resolvedName;
setOrganization(orgId, organizationNameForStore, externalId);
const existingSecretsResult =
await listOrganizationSecretsByOrganizationId(orgId);
@@ -254,26 +364,29 @@ export function useOrgSetupSubmission({
? (existingSecretsResult.data[0]?.id as string | undefined)
: undefined;
let secretResult;
if (existingSecretId) {
const patchSecretFormData = new FormData();
patchSecretFormData.set("organizationSecretId", existingSecretId);
patchSecretFormData.set("roleArn", data.roleArn);
patchSecretFormData.set("externalId", stackSetExternalId);
secretResult = await updateOrganizationSecret(patchSecretFormData);
} else {
const createSecretFormData = new FormData();
createSecretFormData.set("organizationId", orgId);
createSecretFormData.set("roleArn", data.roleArn);
createSecretFormData.set("externalId", stackSetExternalId);
secretResult = await createOrganizationSecret(createSecretFormData);
// Warn before overwriting an existing credential: replacing it
// re-authenticates every provider already onboarded under the org.
if (existingSecretId && !options?.confirmReplace) {
pendingSubmitDataRef.current = data;
if (!isCancelled()) {
setReplaceSecretWarning({
providerCount: getOrganizationProviderCount(existingOrganization),
});
}
return;
}
const secretPayload = strategy.buildSecretPayload(stackSetExternalId);
const secretResult = existingSecretId
? await updateOrganizationSecret(existingSecretId, secretPayload)
: await createOrganizationSecret(orgId, secretPayload);
if (isCancelled()) {
return;
}
if (secretResult?.error) {
handleServerError(secretResult, "Secret");
handleServerError(secretResult, "Secret", strategy);
return;
}
@@ -287,39 +400,162 @@ export function useOrgSetupSubmission({
return;
}
const discoveryId = discoveryResult.data.id;
const resolvedDiscoveryResult = await pollDiscoveryResult(
const discoveryId = discoveryResult?.data?.id;
if (!discoveryId) {
setApiErrorIfActive(UNEXPECTED_DISCOVERY_RESULT);
return;
}
// Persist the discovery id at trigger time so an interrupted discovery
// can be resumed on wizard re-entry.
setDiscoveryTriggered(discoveryId);
resumeContextRef.current = { orgId, discoveryId, strategy };
const outcome = await pollDiscoveryResult(
orgId,
discoveryId,
abortController.signal,
strategy,
);
if (!resolvedDiscoveryResult || isCancelled()) {
return;
// Discovery came back: from here on, credentials are proven good.
if (outcome.kind === "resolved") {
hasDiscovered = true;
}
// The deployment (management/delegated admin) account is where the local
// role is created; its ID is the one embedded in the Role ARN.
const deploymentAccountId = data.roleArn.match(
/^arn:aws:iam::(\d{12}):role\//,
)?.[1];
const selectableAccountIds = getSelectableAccountIdsForTarget(
resolvedDiscoveryResult,
data.organizationalUnitId ?? "",
deploymentAccountId,
);
setDiscovery(discoveryId, resolvedDiscoveryResult);
setSelectedAccountIds(selectableAccountIds);
onNext();
handlePollOutcome(outcome, discoveryId, strategy, abortController.signal);
} catch {
if (!isCancelled()) {
// Ingesting the result is the only work left once `hasDiscovered` is set,
// and a malformed result is not a credentials problem.
setApiError(
"Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.",
hasDiscovered
? UNEXPECTED_DISCOVERY_RESULT
: strategy.authFailureMessage(),
);
}
} finally {
// Only the newest chain clears the flag; a later one that superseded this
// controller owns the pending state and is still running.
if (discoveryAbortControllerRef.current === abortController) {
discoveryAbortControllerRef.current = null;
setIsSubmissionPending(false);
}
}
};
const confirmSecretReplace = () => {
const data = pendingSubmitDataRef.current;
setReplaceSecretWarning(null);
if (data) {
void submitOrganizationSetup(data, { confirmReplace: true });
}
};
const cancelSecretReplace = () => {
setReplaceSecretWarning(null);
};
// *Keep waiting*: resume polling the same discovery with a fresh attempt budget.
const keepWaitingForDiscovery = async () => {
const ctx = resumeContextRef.current;
if (!ctx) {
return;
}
discoveryAbortControllerRef.current?.abort();
const abortController = new AbortController();
discoveryAbortControllerRef.current = abortController;
setDiscoveryTimedOut(false);
setDiscoveryFailed(false);
setIsSubmissionPending(true);
try {
const outcome = await pollDiscoveryResult(
ctx.orgId,
ctx.discoveryId,
abortController.signal,
ctx.strategy,
);
handlePollOutcome(
outcome,
ctx.discoveryId,
ctx.strategy,
abortController.signal,
);
} catch {
if (isMountedRef.current && !abortController.signal.aborted) {
setApiError(UNEXPECTED_DISCOVERY_RESULT);
// The discovery is still running server-side, so restore the two-action
// notice rather than forcing a fresh one.
setDiscoveryTimedOut(true);
}
} finally {
// Only the newest chain clears the flag; a later one that superseded this
// controller owns the pending state and is still running.
if (discoveryAbortControllerRef.current === abortController) {
discoveryAbortControllerRef.current = null;
setIsSubmissionPending(false);
}
}
};
// *Retry*: trigger a new discovery on the same organization, then poll it.
const retryDiscovery = async () => {
const ctx = resumeContextRef.current;
if (!ctx) {
return;
}
discoveryAbortControllerRef.current?.abort();
const abortController = new AbortController();
discoveryAbortControllerRef.current = abortController;
setDiscoveryTimedOut(false);
setDiscoveryFailed(false);
setIsSubmissionPending(true);
setApiError(null);
try {
const discoveryResult = await triggerDiscovery(ctx.orgId);
if (abortController.signal.aborted || !isMountedRef.current) {
return;
}
if (discoveryResult?.error) {
setApiError(discoveryResult.error);
// A retry that could not be triggered is itself a discovery failure —
// without this the retry button disappears.
setDiscoveryFailed(true);
return;
}
const discoveryId = discoveryResult?.data?.id;
if (!discoveryId) {
setApiError(UNEXPECTED_DISCOVERY_RESULT);
setDiscoveryFailed(true);
return;
}
setDiscoveryTriggered(discoveryId);
resumeContextRef.current = { ...ctx, discoveryId };
const outcome = await pollDiscoveryResult(
ctx.orgId,
discoveryId,
abortController.signal,
ctx.strategy,
);
handlePollOutcome(
outcome,
discoveryId,
ctx.strategy,
abortController.signal,
);
} catch {
if (isMountedRef.current && !abortController.signal.aborted) {
// The credentials were accepted once already, so this is not an auth problem.
setApiError(UNEXPECTED_DISCOVERY_RESULT);
setDiscoveryFailed(true);
}
} finally {
// Only the newest chain clears the flag; a later one that superseded this
// controller owns the pending state and is still running.
if (discoveryAbortControllerRef.current === abortController) {
discoveryAbortControllerRef.current = null;
setIsSubmissionPending(false);
}
}
};
@@ -328,5 +564,13 @@ export function useOrgSetupSubmission({
apiError,
setApiError,
submitOrganizationSetup,
replaceSecretWarning,
confirmSecretReplace,
cancelSecretReplace,
discoveryTimedOut,
discoveryFailed,
isSubmissionPending,
keepWaitingForDiscovery,
retryDiscovery,
};
}
@@ -1,126 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { APPLY_STATUS } from "@/types/organizations";
import { OrgAccountSelection } from "./org-account-selection";
const { useOrgAccountSelectionFlowMock, handleTreeSelectionChangeMock } =
vi.hoisted(() => ({
useOrgAccountSelectionFlowMock: vi.fn(),
handleTreeSelectionChangeMock: vi.fn(),
}));
vi.mock("./hooks/use-org-account-selection-flow", () => ({
useOrgAccountSelectionFlow: useOrgAccountSelectionFlowMock,
}));
describe("OrgAccountSelection", () => {
let baseFlowState: Record<string, unknown>;
beforeEach(() => {
useOrgAccountSelectionFlowMock.mockReset();
handleTreeSelectionChangeMock.mockReset();
const accountLookup = new Map([
[
"222222222222",
{
id: "222222222222",
name: "Account Two",
arn: "arn:aws:organizations::222222222222:account/o-123/222222222222",
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
],
]);
baseFlowState = {
accountAliases: {},
accountLookup,
applyError: null,
canAdvanceToLaunch: false,
discoveryResult: {
roots: [],
organizational_units: [],
accounts: [],
},
handleTreeSelectionChange: handleTreeSelectionChangeMock,
hasConnectionErrors: true,
isTesting: false,
isTestingView: true,
isSelectionLocked: false,
organizationExternalId: "o-abc123def4",
selectedCount: 1,
selectedIdsForTree: [],
setAccountAlias: vi.fn(),
showHeaderHelperText: true,
totalAccounts: 2,
treeDataWithConnectionState: [
{
id: "222222222222",
name: "222222222222 - Account Two",
},
],
};
useOrgAccountSelectionFlowMock.mockReturnValue(baseFlowState);
});
it("allows changing account selection after finishing connection tests", async () => {
// Given
const user = userEvent.setup();
render(
<OrgAccountSelection
onBack={vi.fn()}
onNext={vi.fn()}
onSkip={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("checkbox"));
// Then
expect(handleTreeSelectionChangeMock).toHaveBeenCalledWith([
"222222222222",
]);
});
it("locks account selection while apply or connection test is running", async () => {
// Given
const user = userEvent.setup();
useOrgAccountSelectionFlowMock.mockReturnValue({
...baseFlowState,
isSelectionLocked: true,
});
render(
<OrgAccountSelection
onBack={vi.fn()}
onNext={vi.fn()}
onSkip={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("checkbox"));
// Then
expect(handleTreeSelectionChangeMock).not.toHaveBeenCalled();
});
});
@@ -2,13 +2,15 @@
import { AlertTriangle } from "lucide-react";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
import { TreeView } from "@/components/shadcn/tree-view";
import { useOrgAccountSelectionFlow } from "./hooks/use-org-account-selection-flow";
import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item";
import { getOrgCandidateNoun, getOrgProviderBadge } from "./org-terminology";
interface OrgAccountSelectionProps {
onBack: () => void;
@@ -24,11 +26,11 @@ export function OrgAccountSelection({
onFooterChange,
}: OrgAccountSelectionProps) {
const {
accountAliases,
accountLookup,
candidateAliases,
candidateLookup,
applyError,
canAdvanceToLaunch,
discoveryResult,
hierarchy,
handleTreeSelectionChange,
hasConnectionErrors,
isTesting,
@@ -37,10 +39,13 @@ export function OrgAccountSelection({
organizationExternalId,
selectedCount,
selectedIdsForTree,
setAccountAlias,
setCandidateAlias,
showHeaderHelperText,
totalAccounts,
totalCandidates,
treeDataWithConnectionState,
replaceWarning,
confirmReplaceAndApply,
cancelReplace,
} = useOrgAccountSelectionFlow({
onBack,
onNext,
@@ -48,7 +53,7 @@ export function OrgAccountSelection({
onFooterChange,
});
if (!discoveryResult) {
if (!hierarchy) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
No discovery data available.
@@ -56,11 +61,59 @@ export function OrgAccountSelection({
);
}
const OrgBadge = getOrgProviderBadge(hierarchy.orgType);
const noun = getOrgCandidateNoun(hierarchy.orgType);
return (
<div className="flex min-h-0 flex-1 flex-col gap-5">
<Modal
open={replaceWarning !== null}
scrollable
onOpenChange={(open) => {
if (!open) cancelReplace();
}}
title="Replace existing credentials?"
description={`Applying this selection will overwrite the credentials of ${
replaceWarning?.names.length ?? 0
} already-onboarded ${
(replaceWarning?.names.length ?? 0) === 1
? noun.singular
: noun.plural
}.`}
>
{replaceWarning && (
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
{replaceWarning.names.length === 1
? `The following ${noun.singular} will have its credentials replaced: `
: `The following ${noun.plural} will have their credentials replaced: `}
<strong>{replaceWarning.names.join(", ")}</strong>.
</p>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={cancelReplace}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
onClick={confirmReplaceAndApply}
>
Replace and continue
</Button>
</div>
</div>
)}
</Modal>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<OrgBadge size={32} />
<h3 className="text-base font-semibold">My Organization</h3>
</div>
@@ -76,10 +129,10 @@ export function OrgAccountSelection({
{showHeaderHelperText && (
<p className="text-muted-foreground text-sm">
{isTestingView
? "Testing account connections..."
: "Confirm all accounts under this Organization you want to add to Prowler."}{" "}
? `Testing ${noun.singular} connections...`
: `Confirm all ${noun.plural} under this Organization you want to add to Prowler.`}{" "}
{!isTestingView &&
`${selectedCount} of ${totalAccounts} accounts selected.`}
`${selectedCount} of ${totalCandidates} ${noun.plural} selected.`}
</p>
)}
</div>
@@ -98,8 +151,8 @@ export function OrgAccountSelection({
<AlertTriangle />
<AlertDescription className="text-text-error-primary">
{canAdvanceToLaunch
? "There was a problem connecting to some accounts. Hover each account to check the error."
: "No accounts connected successfully. Fix the connection errors and retry before launching scans."}
? `There was a problem connecting to some ${noun.plural}. Hover each ${noun.singular} to check the error.`
: `No ${noun.plural} connected successfully. Fix the connection errors and retry before launching scans.`}
</AlertDescription>
</Alert>
)}
@@ -118,9 +171,10 @@ export function OrgAccountSelection({
<OrgAccountTreeItem
params={params}
mode={TREE_ITEM_MODE.SELECTION}
accountLookup={accountLookup}
aliases={accountAliases}
onAliasChange={setAccountAlias}
orgType={hierarchy.orgType}
candidateLookup={candidateLookup}
aliases={candidateAliases}
onAliasChange={setCandidateAlias}
/>
)}
/>
@@ -3,120 +3,118 @@ import { describe, expect, it, vi } from "vitest";
import { CONNECTION_TEST_STATUS } from "@/types/organizations";
import {
buildAccountToProviderMap,
buildCandidateToProviderMap,
canAdvanceToLaunchStep,
getLaunchableProviderIds,
pollConnectionTask,
runWithConcurrencyLimit,
pollConnectionTasks,
} from "./org-account-selection.utils";
describe("buildAccountToProviderMap", () => {
it("uses explicit account-provider mappings when apply response is unordered", async () => {
// Given
const resolveProviderUidById = vi.fn();
const selectedAccountIds = ["111111111111", "222222222222"];
describe("buildCandidateToProviderMap", () => {
it("matches providers to candidates by uid, not by position", async () => {
// Given — relationship order is not selection order, so pairing them by index
// would mismatch every candidate.
const selectedCandidateIds = ["111111111111", "222222222222"];
const providerIds = ["provider-b", "provider-a"];
const applyResult = {
data: {
attributes: {
account_provider_mappings: [
{
account_id: "111111111111",
provider_id: "provider-a",
},
{
account_id: "222222222222",
provider_id: "provider-b",
},
],
},
},
};
const resolveProviderUids = vi.fn(async () => ({
"provider-a": "111111111111",
"provider-b": "222222222222",
}));
// When
const map = await buildAccountToProviderMap({
selectedAccountIds,
const map = await buildCandidateToProviderMap({
selectedCandidateIds,
providerIds,
applyResult,
resolveProviderUidById,
resolveProviderUids,
});
// Then
// Then — resolved in one call, for all providers at once.
expect(map.get("111111111111")).toBe("provider-a");
expect(map.get("222222222222")).toBe("provider-b");
expect(resolveProviderUidById).not.toHaveBeenCalled();
expect(resolveProviderUids).toHaveBeenCalledTimes(1);
expect(resolveProviderUids).toHaveBeenCalledWith(providerIds);
});
it("falls back to provider uid matching when explicit mappings are missing", async () => {
// Given
const selectedAccountIds = ["111111111111", "222222222222"];
it("leaves out providers whose uid did not resolve or is outside the selection", async () => {
// Given — one provider resolves to a candidate nobody selected, one not at all.
const selectedCandidateIds = ["111111111111", "222222222222"];
const providerIds = ["provider-a", "provider-b", "provider-c"];
const resolveProviderUidById = vi.fn(async (providerId: string) => {
if (providerId === "provider-a") return "222222222222";
if (providerId === "provider-c") return "111111111111";
return "999999999999";
});
const resolveProviderUids = vi.fn(async () => ({
"provider-a": "222222222222",
"provider-b": "999999999999",
}));
// When
const map = await buildAccountToProviderMap({
selectedAccountIds,
const map = await buildCandidateToProviderMap({
selectedCandidateIds,
providerIds,
applyResult: {},
resolveProviderUidById,
resolveProviderUids,
});
// Then
expect(map.get("111111111111")).toBe("provider-c");
expect(map.get("222222222222")).toBe("provider-a");
expect(map.size).toBe(1);
});
});
describe("runWithConcurrencyLimit", () => {
it("processes work with the configured concurrency cap", async () => {
// Given
const items = Array.from({ length: 8 }, (_, index) => index + 1);
let activeWorkers = 0;
let maxActiveWorkers = 0;
const executing = { data: { attributes: { state: "executing" } } };
const completed = (connected: boolean, error?: string) => ({
data: { attributes: { state: "completed", result: { connected, error } } },
});
describe("pollConnectionTasks", () => {
it("reports each task the round it settles instead of waiting for the slowest", async () => {
// Given — one account connects on the first round, the other three rounds later.
const rounds: string[][] = [];
const getTasksByIds = vi.fn(async (taskIds: string[]) => {
rounds.push([...taskIds]);
const round = rounds.length;
return {
"task-fast": round >= 1 ? completed(true) : executing,
"task-slow":
round >= 3
? completed(false, "Role trust policy mismatch.")
: executing,
};
});
const settled: Array<[string, unknown]> = [];
// When
const results = await runWithConcurrencyLimit(items, 3, async (item) => {
activeWorkers += 1;
maxActiveWorkers = Math.max(maxActiveWorkers, activeWorkers);
await new Promise((resolve) => setTimeout(resolve, 1));
activeWorkers -= 1;
return item * 2;
await pollConnectionTasks(["task-fast", "task-slow"], {
onSettled: (taskId, result) => settled.push([taskId, result]),
getTasksByIds,
sleep: async () => {},
maxRetries: 5,
});
// Then
expect(maxActiveWorkers).toBeLessThanOrEqual(3);
expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]);
// Then — the fast one is reported after round 1 and dropped from later reads,
// while the slow one is still pending.
expect(settled).toEqual([
["task-fast", { success: true }],
["task-slow", { success: false, error: "Role trust policy mismatch." }],
]);
expect(rounds).toEqual([
["task-fast", "task-slow"],
["task-slow"],
["task-slow"],
]);
});
});
describe("pollConnectionTask", () => {
it("uses progressive delays and returns connection result from the final task payload", async () => {
// Given
it("reads every pending task in one call per round, with progressive delays", async () => {
// Given — a client-side loop would cost one round trip per task per round.
const sleeps: number[] = [];
const getTaskById = vi
const getTasksByIds = vi
.fn()
.mockResolvedValueOnce({ "task-a": executing, "task-b": executing })
.mockResolvedValueOnce({ "task-a": executing, "task-b": executing })
.mockResolvedValueOnce({
data: { attributes: { state: "executing" } },
})
.mockResolvedValueOnce({
data: { attributes: { state: "executing" } },
})
.mockResolvedValueOnce({
data: {
attributes: {
state: "completed",
result: { connected: false, error: "Role trust policy mismatch." },
},
},
"task-a": completed(true),
"task-b": completed(true),
});
// When
const result = await pollConnectionTask("task-1", {
getTaskById,
await pollConnectionTasks(["task-a", "task-b"], {
onSettled: () => {},
getTasksByIds,
sleep: async (delay) => {
sleeps.push(delay);
},
@@ -124,38 +122,84 @@ describe("pollConnectionTask", () => {
});
// Then
expect(getTasksByIds).toHaveBeenCalledTimes(3);
expect(sleeps).toEqual([2000, 3000]);
expect(getTaskById).toHaveBeenCalledTimes(3);
expect(result).toEqual({
success: false,
error: "Role trust policy mismatch.",
});
});
it("stops polling when aborted", async () => {
it("stops polling when aborted and cancels whatever had not settled", async () => {
// Given
const abortController = new AbortController();
const getTaskById = vi
.fn()
.mockResolvedValue({ data: { attributes: { state: "executing" } } });
const getTasksByIds = vi.fn(async () => ({
"task-a": completed(true),
"task-b": executing,
}));
const sleep = vi.fn(async () => {
abortController.abort();
});
const settled: Array<[string, unknown]> = [];
// When
const result = await pollConnectionTask("task-1", {
getTaskById,
await pollConnectionTasks(["task-a", "task-b"], {
onSettled: (taskId, result) => settled.push([taskId, result]),
getTasksByIds,
sleep,
signal: abortController.signal,
maxRetries: 5,
});
// Then
expect(getTaskById).toHaveBeenCalledTimes(1);
expect(result).toEqual({
success: false,
error: "Connection test cancelled.",
// Then — the settled result stands; the pending one is reported cancelled.
expect(getTasksByIds).toHaveBeenCalledTimes(1);
expect(settled).toEqual([
["task-a", { success: true }],
["task-b", { success: false, error: "Connection test cancelled." }],
]);
});
it("times out only the tasks that never settled", async () => {
// Given
const getTasksByIds = vi.fn(async () => ({
"task-a": completed(true),
"task-b": executing,
}));
const settled: Array<[string, unknown]> = [];
// When
await pollConnectionTasks(["task-a", "task-b"], {
onSettled: (taskId, result) => settled.push([taskId, result]),
getTasksByIds,
sleep: async () => {},
maxRetries: 2,
});
// Then
expect(settled).toEqual([
["task-a", { success: true }],
["task-b", { success: false, error: "Connection test timed out." }],
]);
});
it("surfaces a per-task read failure without touching the rest of the batch", async () => {
// Given — the batch read reports one task's failure under its own key.
const getTasksByIds = vi.fn(async () => ({
"task-a": completed(true),
"task-b": { error: "Task not found." },
}));
const settled: Array<[string, unknown]> = [];
// When
await pollConnectionTasks(["task-a", "task-b"], {
onSettled: (taskId, result) => settled.push([taskId, result]),
getTasksByIds,
sleep: async () => {},
maxRetries: 5,
});
// Then
expect(getTasksByIds).toHaveBeenCalledTimes(1);
expect(settled).toEqual([
["task-a", { success: true }],
["task-b", { success: false, error: "Task not found." }],
]);
});
});
@@ -3,19 +3,15 @@ import {
ConnectionTestStatus,
} from "@/types/organizations";
const DEFAULT_CONCURRENCY_LIMIT = 5;
const DEFAULT_POLL_DELAYS_MS = [2000, 3000, 5000] as const;
interface AccountProviderMapping {
account_id: string;
provider_id: string;
}
interface BuildAccountToProviderMapParams {
selectedAccountIds: string[];
interface BuildCandidateToProviderMapParams {
selectedCandidateIds: string[];
providerIds: string[];
applyResult: unknown;
resolveProviderUidById: (providerId: string) => Promise<string | null>;
/** Uids of the given providers, keyed by provider id. */
resolveProviderUids: (
providerIds: string[],
) => Promise<Record<string, string>>;
}
interface PollConnectionTaskOptions {
@@ -26,6 +22,13 @@ interface PollConnectionTaskOptions {
signal?: AbortSignal;
}
interface PollConnectionTasksOptions
extends Omit<PollConnectionTaskOptions, "getTaskById"> {
/** Called once per task, the round it reaches a terminal state. */
onSettled: (taskId: string, result: PollConnectionTaskResult) => void;
getTasksByIds?: (taskIds: string[]) => Promise<Record<string, unknown>>;
}
export interface PollConnectionTaskResult {
success: boolean;
error?: string;
@@ -78,146 +81,173 @@ function sleepWithAbort(
});
}
function normalizeAccountProviderMapping(
value: unknown,
): AccountProviderMapping | null {
if (!isRecord(value)) {
return null;
}
const attributes = isRecord(value.attributes) ? value.attributes : null;
const accountId =
(typeof value.account_id === "string" && value.account_id) ||
(typeof attributes?.account_id === "string" && attributes.account_id) ||
(typeof value.id === "string" && value.id) ||
null;
const providerId =
(typeof value.provider_id === "string" && value.provider_id) ||
(typeof attributes?.provider_id === "string" && attributes.provider_id) ||
null;
if (!accountId || !providerId) {
return null;
}
return {
account_id: accountId,
provider_id: providerId,
};
}
function extractAccountProviderMappings(applyResult: unknown) {
if (!isRecord(applyResult)) {
return [];
}
const data = isRecord(applyResult.data) ? applyResult.data : null;
if (!data) {
return [];
}
const attributes = isRecord(data.attributes) ? data.attributes : null;
const relationships = isRecord(data.relationships)
? data.relationships
: null;
const attributeMappings = Array.isArray(attributes?.account_provider_mappings)
? attributes.account_provider_mappings
: [];
const relationshipNode = isRecord(relationships?.account_provider_mappings)
? relationships.account_provider_mappings
: null;
const relationshipMappings = Array.isArray(relationshipNode?.data)
? relationshipNode.data
: [];
return [...attributeMappings, ...relationshipMappings]
.map(normalizeAccountProviderMapping)
.filter((mapping): mapping is AccountProviderMapping => mapping !== null);
}
export async function runWithConcurrencyLimit<T, R>(
items: T[],
concurrencyLimit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) {
return [];
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
const results = new Array<R>(items.length);
let currentIndex = 0;
const runWorker = async () => {
while (currentIndex < items.length) {
const assignedIndex = currentIndex;
currentIndex += 1;
results[assignedIndex] = await worker(
items[assignedIndex],
assignedIndex,
);
}
};
const workers = Array.from(
{ length: Math.min(normalizedConcurrency, items.length) },
() => runWorker(),
);
await Promise.all(workers);
return results;
}
export async function buildAccountToProviderMap({
selectedAccountIds,
/**
* Candidate id → the provider created for it. The apply response carries provider
* ids only, so the uids identifying each candidate are read separately. A provider
* with no resolved uid is left out rather than matched by position, which the
* relationship order does not guarantee.
*/
export async function buildCandidateToProviderMap({
selectedCandidateIds,
providerIds,
applyResult,
resolveProviderUidById,
}: BuildAccountToProviderMapParams): Promise<Map<string, string>> {
const selectedAccountIdSet = new Set(selectedAccountIds);
resolveProviderUids,
}: BuildCandidateToProviderMapParams): Promise<Map<string, string>> {
const selectedCandidateIdSet = new Set(selectedCandidateIds);
const uidByProviderId = await resolveProviderUids(providerIds);
const mapping = new Map<string, string>();
const explicitMappings = extractAccountProviderMappings(applyResult);
if (explicitMappings.length > 0) {
const mappedProviders = new Map<string, string>();
for (const mapping of explicitMappings) {
if (!selectedAccountIdSet.has(mapping.account_id)) {
continue;
}
mappedProviders.set(mapping.account_id, mapping.provider_id);
}
if (mappedProviders.size > 0) {
return mappedProviders;
}
}
const fallbackEntries = await runWithConcurrencyLimit(
providerIds,
DEFAULT_CONCURRENCY_LIMIT,
async (providerId) => {
const providerUid = await resolveProviderUidById(providerId);
if (!providerUid || !selectedAccountIdSet.has(providerUid)) {
return null;
}
return { accountId: providerUid, providerId };
},
);
const fallbackMapping = new Map<string, string>();
for (const entry of fallbackEntries) {
if (!entry) {
for (const providerId of providerIds) {
const candidateId = uidByProviderId[providerId];
if (!candidateId || !selectedCandidateIdSet.has(candidateId)) {
continue;
}
fallbackMapping.set(entry.accountId, entry.providerId);
mapping.set(candidateId, providerId);
}
return fallbackMapping;
return mapping;
}
export async function pollConnectionTask(
const IN_PROGRESS_TASK_STATES = new Set([
"available",
"scheduled",
"executing",
"pending",
"running",
]);
/**
* The connection outcome a task payload carries, or `null` while it is still
* running. An unreadable payload counts as terminal rather than polled forever.
*/
function readConnectionOutcome(
taskResponse: unknown,
): PollConnectionTaskResult | null {
if (isRecord(taskResponse) && typeof taskResponse.error === "string") {
return { success: false, error: taskResponse.error };
}
const data =
isRecord(taskResponse) && isRecord(taskResponse.data)
? taskResponse.data
: null;
const attributes = isRecord(data?.attributes) ? data.attributes : null;
const state = typeof attributes?.state === "string" ? attributes.state : null;
const result = isRecord(attributes?.result) ? attributes.result : null;
if (state === "completed") {
const connected =
typeof result?.connected === "boolean" ? result.connected : true;
if (connected) {
return { success: true };
}
return {
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection failed for this account.",
};
}
if (state === "failed") {
return {
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection test task failed.",
};
}
if (!state || !IN_PROGRESS_TASK_STATES.has(state)) {
return { success: false, error: "Unexpected task state." };
}
return null;
}
/**
* Polls a whole batch of connection tasks, reporting each one through `onSettled`
* the round it settles.
*
* Whatever is still running is read in a single call per round: client-invoked
* server actions run one at a time through Next's action queue, so polling task
* by task would cost a round trip per task per round and stall every other action
* behind it.
*/
export async function pollConnectionTasks(
taskIds: string[],
{
onSettled,
getTasksByIds,
sleep = async (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms)),
maxRetries = 20,
delaysMs = [...DEFAULT_POLL_DELAYS_MS],
signal,
}: PollConnectionTasksOptions,
): Promise<void> {
const pending = new Set(taskIds.filter(Boolean));
if (pending.size === 0) {
return;
}
const tasksFetcher =
getTasksByIds ??
(async (currentTaskIds: string[]) => {
const { getTasksByIds: readTasks } = await import("@/actions/task/tasks");
return readTasks(currentTaskIds);
});
const settleRemaining = (error: string) => {
for (const taskId of Array.from(pending)) {
onSettled(taskId, { success: false, error });
}
pending.clear();
};
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
if (signal?.aborted) {
settleRemaining("Connection test cancelled.");
return;
}
const snapshots = await tasksFetcher(Array.from(pending));
if (signal?.aborted) {
settleRemaining("Connection test cancelled.");
return;
}
for (const taskId of Array.from(pending)) {
// A task missing from the batch read gets another round rather than being
// reported as a failure the API never stated.
if (!(taskId in snapshots)) {
continue;
}
const outcome = readConnectionOutcome(snapshots[taskId]);
if (!outcome) {
continue;
}
pending.delete(taskId);
onSettled(taskId, outcome);
}
if (pending.size === 0) {
return;
}
await sleepWithAbort(getPollingDelay(attempt, delaysMs), sleep, signal);
}
settleRemaining("Connection test timed out.");
}
/**
* Polls a generic async task until it settles. Unlike {@link pollConnectionTasks}
* it does not interpret a connection result; it is used for organization/node
* deletion, which the API answers with a `202` + task.
*/
export async function pollTaskCompletion(
taskId: string,
{
getTaskById,
@@ -228,13 +258,6 @@ export async function pollConnectionTask(
signal,
}: PollConnectionTaskOptions = {},
): Promise<PollConnectionTaskResult> {
const inProgressStates = new Set([
"available",
"scheduled",
"executing",
"pending",
"running",
]);
const taskFetcher =
getTaskById ??
(async (currentTaskId: string) => {
@@ -244,12 +267,12 @@ export async function pollConnectionTask(
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
if (signal?.aborted) {
return { success: false, error: "Connection test cancelled." };
return { success: false, error: "Deletion cancelled." };
}
const taskResponse = await taskFetcher(taskId);
if (signal?.aborted) {
return { success: false, error: "Connection test cancelled." };
return { success: false, error: "Deletion cancelled." };
}
if (isRecord(taskResponse) && typeof taskResponse.error === "string") {
@@ -266,17 +289,7 @@ export async function pollConnectionTask(
const result = isRecord(attributes?.result) ? attributes.result : null;
if (state === "completed") {
const connected =
typeof result?.connected === "boolean" ? result.connected : true;
if (connected) {
return { success: true };
}
return {
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection failed for this account.",
};
return { success: true };
}
if (state === "failed") {
@@ -284,18 +297,23 @@ export async function pollConnectionTask(
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection test task failed.",
"The deletion task failed.",
};
}
if (!state || !inProgressStates.has(state)) {
// A cancelled task is a real terminal state, not an unreadable one.
if (state === "cancelled") {
return { success: false, error: "The deletion was cancelled." };
}
if (!state || !IN_PROGRESS_TASK_STATES.has(state)) {
return { success: false, error: "Unexpected task state." };
}
await sleepWithAbort(getPollingDelay(attempt, delaysMs), sleep, signal);
}
return { success: false, error: "Connection test timed out." };
return { success: false, error: "Deletion timed out." };
}
export function getLaunchableProviderIds(
@@ -1,6 +1,6 @@
"use client";
import { AlertCircle } from "lucide-react";
import { AlertCircle, CircleSlash } from "lucide-react";
import { Input } from "@/components/shadcn/input/input";
import {
@@ -8,8 +8,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
getCandidateNoun,
getNodeLabel,
toNodeKind,
} from "@/lib/organizations";
import { cn } from "@/lib/utils";
import { APPLY_STATUS, DiscoveredAccount } from "@/types/organizations";
import {
APPLY_STATUS,
NODE_KIND,
OrgCandidate,
OrgFlowType,
} from "@/types/organizations";
import { TreeRenderItemParams } from "@/types/tree";
const TREE_ITEM_MODE = {
@@ -21,75 +31,142 @@ type TreeItemMode = (typeof TREE_ITEM_MODE)[keyof typeof TREE_ITEM_MODE];
interface OrgAccountTreeItemProps {
params: TreeRenderItemParams;
mode: TreeItemMode;
accountLookup: Map<string, DiscoveredAccount>;
orgType: OrgFlowType;
candidateLookup: Map<string, OrgCandidate>;
aliases: Record<string, string>;
onAliasChange?: (accountId: string, alias: string) => void;
onAliasChange?: (candidateId: string, alias: string) => void;
}
/**
* Why a container row is inert, in this organization's own vocabulary ("No
* projects available to select in this folder." for GCP, accounts/OUs for AWS).
* The note is also the icon's `aria-label` so a screen reader reaches it without
* a hover.
*/
function InertContainerNote({
orgType,
kind,
}: {
orgType: OrgFlowType;
kind?: string;
}) {
const note = `No ${getCandidateNoun(orgType).plural} available to select in this ${getNodeLabel(
orgType,
toNodeKind(kind),
).toLowerCase()}.`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span role="img" aria-label={note}>
<CircleSlash className="text-text-neutral-tertiary size-4 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent side="top">{note}</TooltipContent>
</Tooltip>
);
}
/**
* An identifier in the fixed-width id column. GCP project ids run to 30
* characters and AWS OU ids longer still, so the text ellipsizes and the full
* value moves to a tooltip.
*/
function TruncatedId({
value,
className,
}: {
value: string;
className?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate text-sm", className)}>{value}</span>
</TooltipTrigger>
<TooltipContent side="top">{value}</TooltipContent>
</Tooltip>
);
}
export function OrgAccountTreeItem({
params,
mode,
accountLookup,
orgType,
candidateLookup,
aliases,
onAliasChange,
}: OrgAccountTreeItemProps) {
const { item, isLeaf } = params;
const account = accountLookup.get(item.id);
const isOuNode = item.id.startsWith("ou-");
const candidate = candidateLookup.get(item.id);
const ItemIcon = item.icon;
const idColumnClass = "w-44 shrink-0";
// `min-w-0` alongside the fixed width, or a long id widens the column past
// 176px and overruns the alias input instead of ellipsizing.
const idColumnClass = "w-44 min-w-0 shrink-0";
const aliasInputClass = "h-9 w-full max-w-64 text-sm";
// OU nodes: show OU id + alias/name (input in selection mode).
if (!account && isOuNode) {
const ouDisplayName = aliases[item.id] ?? item.name;
const isSelectionMode = mode === TREE_ITEM_MODE.SELECTION && onAliasChange;
// Container node (OU / folder) — presence in candidateLookup, not an ID
// prefix, decides this. AWS organizational units keep the editable-name
// input; other container kinds (e.g. GCP folders) render read-only.
if (!candidate) {
const nodeDisplayName = aliases[item.id] ?? item.name;
// A disabled container has nothing to apply, so its name would never be sent.
const isEditableNode =
mode === TREE_ITEM_MODE.SELECTION &&
onAliasChange &&
!item.disabled &&
toNodeKind(item.kind) === NODE_KIND.ORGANIZATIONAL_UNIT;
return (
<div className="flex flex-1 items-center gap-3">
<div className={`${idColumnClass} flex items-center gap-2`}>
<div className={cn(idColumnClass, "flex items-center gap-2")}>
{ItemIcon && (
<ItemIcon className="text-muted-foreground size-4 shrink-0" />
<ItemIcon className="text-text-neutral-tertiary size-4 shrink-0" />
)}
<span className="text-sm">{item.id}</span>
<TruncatedId value={item.id} />
</div>
<div className="min-w-0 flex-1">
{isSelectionMode ? (
{isEditableNode ? (
<Input
className={aliasInputClass}
placeholder="Name (optional)"
value={ouDisplayName}
value={nodeDisplayName}
onChange={(e) => onAliasChange(item.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-muted-foreground line-clamp-1 text-xs">
{ouDisplayName}
<span className="text-text-neutral-tertiary line-clamp-1 text-xs">
{nodeDisplayName}
</span>
)}
</div>
{item.disabled && (
<InertContainerNote orgType={orgType} kind={item.kind} />
)}
</div>
);
}
// Any remaining non-account node (unexpected fallback).
if (!account || !isLeaf) {
// Any remaining non-leaf node (unexpected fallback).
if (!isLeaf) {
return <span className="text-sm font-medium">{item.name}</span>;
}
const isBlocked = account.registration?.apply_status === APPLY_STATUS.BLOCKED;
const blockedReasons = account.registration?.blocked_reasons ?? [];
const isBlocked =
candidate.registration?.apply_status === APPLY_STATUS.BLOCKED;
const blockedReasons = candidate.registration?.blocked_reasons ?? [];
return (
<div className="flex flex-1 items-center gap-3">
{/* Account ID */}
{/* Candidate uid */}
<div className={cn(idColumnClass, "flex items-center gap-2")}>
{ItemIcon && (
<ItemIcon className="text-muted-foreground size-4 shrink-0" />
<ItemIcon className="text-text-neutral-tertiary size-4 shrink-0" />
)}
<span className={cn("text-sm", isBlocked && "text-muted-foreground")}>
{account.id}
</span>
<TruncatedId
value={candidate.uid}
className={isBlocked ? "text-text-neutral-tertiary" : undefined}
/>
</div>
{/* Name / alias input */}
@@ -98,13 +175,13 @@ export function OrgAccountTreeItem({
<Input
className={aliasInputClass}
placeholder="Name (optional)"
value={aliases[account.id] ?? account.name}
onChange={(e) => onAliasChange(account.id, e.target.value)}
value={aliases[candidate.uid] ?? candidate.label}
onChange={(e) => onAliasChange(candidate.uid, e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-muted-foreground line-clamp-1 text-xs">
{aliases[account.id] || account.name}
<span className="text-text-neutral-tertiary line-clamp-1 text-xs">
{aliases[candidate.uid] || candidate.label}
</span>
)}
</div>
@@ -113,7 +190,7 @@ export function OrgAccountTreeItem({
{isBlocked && blockedReasons.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<AlertCircle className="text-destructive size-4 shrink-0" />
<AlertCircle className="text-text-error-primary size-4 shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{blockedReasons.join(", ")}</p>
@@ -77,11 +77,8 @@ describe("OrgLaunchScan", () => {
});
updateSchedulesBulkMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: PROVIDER_IDS,
failed: [],
},
updated: PROVIDER_IDS,
failed: [],
},
});
useOrgSetupStore.getState().reset();
@@ -137,11 +134,8 @@ describe("OrgLaunchScan", () => {
const onFooterChange = vi.fn();
updateSchedulesBulkMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: ["provider-2"],
failed: [{ id: "provider-1", error: "Denied" }],
},
updated: ["provider-2"],
failed: [{ id: "provider-1", error: "Denied" }],
},
});
@@ -251,14 +245,11 @@ describe("OrgLaunchScan", () => {
const onFooterChange = vi.fn();
updateSchedulesBulkMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: [],
failed: [
{ id: "provider-1", error: "Denied" },
{ id: "provider-2", error: "Denied" },
],
},
updated: [],
failed: [
{ id: "provider-1", error: "Denied" },
{ id: "provider-2", error: "Denied" },
],
},
});
@@ -283,7 +274,8 @@ describe("OrgLaunchScan", () => {
expect.objectContaining({
variant: "destructive",
title: "Unable to save scan schedules",
description: "The scan schedule could not be saved for 2 accounts.",
description:
"The scan schedule could not be saved for 2 accounts: Denied.",
}),
),
);
@@ -297,11 +289,8 @@ describe("OrgLaunchScan", () => {
const onFooterChange = vi.fn();
updateSchedulesBulkMock.mockResolvedValue({
data: {
type: "schedules-bulk",
attributes: {
updated: ["provider-2"],
failed: [{ provider_id: "provider-1", error: "Denied" }],
},
updated: ["provider-2"],
failed: [{ id: "provider-1", error: "Denied" }],
},
});
@@ -326,10 +315,52 @@ describe("OrgLaunchScan", () => {
expect.objectContaining({
title: "Scan schedules saved",
description:
"The schedule was saved for 1 account, but 1 account could not be updated.",
"The schedule was saved for 1 account, but 1 account could not be updated: Denied.",
}),
);
});
it("should proceed when the response carries no result lists", async () => {
// Given — an empty 200/204 body. The endpoint commits each schedule before
// answering, so this is not a failure.
const user = userEvent.setup();
const onFooterChange = vi.fn();
updateSchedulesBulkMock.mockResolvedValue({ success: true });
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// When
await user.click(
await screen.findByRole("checkbox", {
name: /launch an initial scan now/i,
}),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then — every created provider is treated as saved and scanned.
await waitFor(() =>
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
PROVIDER_IDS,
"single",
),
);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Scan schedules saved and initial scans launched",
description: "The scan schedule was saved for 2 accounts.",
}),
);
expect(pushMock).toHaveBeenCalledWith("/providers");
});
});
describe("when capability is DAILY_LEGACY", () => {
@@ -8,7 +8,6 @@ import { useForm, useWatch } from "react-hook-form";
import { launchOrganizationScans } from "@/actions/scans/scans";
import { updateSchedulesBulk } from "@/actions/schedules/schedules";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
@@ -28,8 +27,10 @@ import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
getScanScheduleCapability,
getScheduleFormDefaults,
parseSchedulesBulkResult,
scheduleFormSchema,
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
@@ -39,10 +40,15 @@ import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
type ScheduleFormValues,
type SchedulesBulkResponse,
} from "@/types";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
getOrgCandidateNoun,
getOrgProviderBadge,
OrgCandidateNoun,
} from "./org-terminology";
interface OrgLaunchScanProps {
onClose: () => void;
onBack: () => void;
@@ -68,28 +74,8 @@ const SCAN_SCHEDULE = {
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
function getStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
/**
* Providers whose schedule was actually saved. The backend reports successes
* under `updated`, populated only after each provider's schedule commits, so
* it already excludes failures — no client-side subtraction is needed.
*/
function getUpdatedProviderIds(result: SchedulesBulkResponse): string[] {
return getStringArray(result.data?.attributes?.updated);
}
function getFailedCount(result: SchedulesBulkResponse): number {
const failed = result.data?.attributes?.failed;
return Array.isArray(failed) ? failed.length : 0;
}
function formatAccountCount(count: number): string {
return `${count} account${count === 1 ? "" : "s"}`;
function formatCandidateCount(count: number, noun: OrgCandidateNoun): string {
return `${count} ${count === 1 ? noun.singular : noun.plural}`;
}
function getScansHref(tab: (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]) {
@@ -106,8 +92,14 @@ export function OrgLaunchScan({
}: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
const { organizationExternalId, createdProviderIds, reset } =
useOrgSetupStore();
const {
organizationExternalId,
organizationType,
createdProviderIds,
reset,
} = useOrgSetupStore();
const noun = getOrgCandidateNoun(organizationType);
const OrgBadge = getOrgProviderBadge(organizationType);
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
const isAdvanced = resolvedCapability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
@@ -181,12 +173,16 @@ export function OrgLaunchScan({
return;
}
const updatedProviderIds = getUpdatedProviderIds(result);
const failedCount = getFailedCount(result);
const outcome = parseSchedulesBulkResult(result);
const failedCount = outcome.failures.length;
const failureReasons = describeSchedulesBulkFailures(outcome.failures);
// A body we cannot read is not a failure: the endpoint commits each schedule
// before answering, so blocking here would ask the user to save one twice.
const updatedProviderIds = outcome.isIndeterminate
? createdProviderIds
: outcome.updatedProviderIds;
// No provider was actually updated (e.g. the endpoint returned 200 but every
// schedule failed). Surface it as an error and keep the wizard open to retry
// instead of navigating away with a misleading "saved for 0 accounts" toast.
// Every provider failed: keep the wizard open to retry, and name the reason.
if (updatedProviderIds.length === 0) {
setIsLaunching(false);
toast({
@@ -194,8 +190,8 @@ export function OrgLaunchScan({
title: "Unable to save scan schedules",
description:
failedCount > 0
? `The scan schedule could not be saved for ${formatAccountCount(failedCount)}.`
: "The scan schedule could not be saved for any account.",
? `The scan schedule could not be saved for ${formatCandidateCount(failedCount, noun)}${failureReasons ? `: ${failureReasons}.` : "."}`
: `The scan schedule could not be saved for any ${noun.singular}.`,
});
return;
}
@@ -218,8 +214,8 @@ export function OrgLaunchScan({
const updatedCount = updatedProviderIds.length;
const description =
failedCount > 0
? `The schedule was saved for ${formatAccountCount(updatedCount)}, but ${formatAccountCount(failedCount)} could not be updated.`
: `The scan schedule was saved for ${formatAccountCount(updatedCount)}.`;
? `The schedule was saved for ${formatCandidateCount(updatedCount, noun)}, but ${formatCandidateCount(failedCount, noun)} could not be updated${failureReasons ? `: ${failureReasons}.` : "."}`
: `The scan schedule was saved for ${formatCandidateCount(updatedCount, noun)}.`;
const targetTab =
initialScanSuccessCount > 0
? SCAN_JOBS_TAB.ACTIVE
@@ -232,7 +228,7 @@ export function OrgLaunchScan({
: "Scan schedules saved",
description:
initialScanFailureCount > 0
? `${description} Initial scans failed for ${formatAccountCount(initialScanFailureCount)}.`
? `${description} Initial scans failed for ${formatCandidateCount(initialScanFailureCount, noun)}.`
: description,
action: (
<ToastAction altText="Go to scans" asChild>
@@ -266,8 +262,8 @@ export function OrgLaunchScan({
title: "Scan Launched",
description:
effectiveScheduleOption === SCAN_SCHEDULE.DAILY
? `Daily scan scheduled for ${formatAccountCount(successCount)}.`
: `Single scan launched for ${formatAccountCount(successCount)}.`,
? `Daily scan scheduled for ${formatCandidateCount(successCount, noun)}.`
: `Single scan launched for ${formatCandidateCount(successCount, noun)}.`,
action: (
<ToastAction altText="Go to scans" asChild>
<Link href={getScansHref(targetTab)}>Go to scans</Link>
@@ -314,7 +310,7 @@ export function OrgLaunchScan({
<div className="flex min-h-0 flex-1 flex-col gap-8">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<OrgBadge size={32} />
<h3 className="text-base font-semibold">My Organization</h3>
</div>
@@ -348,17 +344,17 @@ export function OrgLaunchScan({
status={TREE_ITEM_STATUS.SUCCESS}
className="size-6"
/>
<h3 className="text-sm font-semibold">Accounts Connected!</h3>
<h3 className="text-sm font-semibold">{noun.Plural} Connected!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Your accounts are connected to Prowler and ready to Scan!
Your {noun.plural} are connected to Prowler and ready to Scan!
</p>
{createdProviderIds.length === 0 && (
<p className="text-text-error-primary text-sm">
No successfully connected accounts are available to launch scans.
Go back and retry connection tests.
No successfully connected {noun.plural} are available to launch
scans. Go back and retry connection tests.
</p>
)}
@@ -374,14 +370,14 @@ export function OrgLaunchScan({
) : isManualOnly ? (
<div className="flex flex-col gap-3">
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for trial accounts. These
accounts will run a one-time manual scan now.
Scheduled scans are not available for trial accounts. These{" "}
{noun.plural} will run a one-time manual scan now.
</p>
</div>
) : isDailyLegacy ? (
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Select a Prowler scan schedule for these accounts.
Select a Prowler scan schedule for these {noun.plural}.
</p>
<Select
value={scheduleOption}
@@ -48,6 +48,13 @@ vi.mock("./hooks/use-org-setup-submission", () => ({
apiError: null,
setApiError: setApiErrorMock,
submitOrganizationSetup: submitOrganizationSetupMock,
replaceSecretWarning: null,
confirmSecretReplace: vi.fn(),
cancelSecretReplace: vi.fn(),
discoveryTimedOut: false,
discoveryFailed: false,
keepWaitingForDiscovery: vi.fn(),
retryDiscovery: vi.fn(),
}),
}));
@@ -24,9 +24,11 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
import { getAWSOrgDeploymentQuickLink } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import type { OrgSetupPhase } from "@/types/organizations";
import { ORG_SETUP_PHASE } from "@/types/organizations";
import { ORG_SETUP_PHASE, ORGANIZATION_TYPE } from "@/types/organizations";
import { DiscoveryTimeoutNotice } from "./discovery-timeout-notice";
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
import { SecretReplaceWarningModal } from "./secret-replace-warning-modal";
const orgSetupSchema = z.object({
organizationName: z.string().trim().optional(),
@@ -159,14 +161,36 @@ export function OrgSetupForm({
})
: null;
const { apiError, setApiError, submitOrganizationSetup } =
useOrgSetupSubmission({
stackSetExternalId,
onNext,
setFieldError: (field, message) => {
setError(field, { message });
},
});
const {
apiError,
setApiError,
submitOrganizationSetup,
replaceSecretWarning,
confirmSecretReplace,
cancelSecretReplace,
discoveryTimedOut,
discoveryFailed,
isSubmissionPending,
keepWaitingForDiscovery,
retryDiscovery,
} = useOrgSetupSubmission({
stackSetExternalId,
onNext,
setFieldError: (field, message) => {
switch (field) {
case "organizationName":
case "awsOrgId":
setError(field, { message });
return true;
default:
return false;
}
},
});
// `isSubmitting` only covers a submit react-hook-form started itself, not the
// chain re-entered by confirming a replacement, keeping waiting or retrying.
const isBusy = isSubmitting || isSubmissionPending;
useEffect(() => {
onPhaseChange(setupPhase);
@@ -192,20 +216,20 @@ export function OrgSetupForm({
onFooterChange({
showBack: !isEditCredentials,
backLabel: "Back",
backDisabled: isSubmitting,
backDisabled: isBusy,
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
showAction: true,
actionLabel: "Authenticate",
actionDisabled: isSubmitting || !isValid || !stackSetExternalId,
actionDisabled: isBusy || !isValid || !stackSetExternalId,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [
formId,
intent,
isBusy,
isOrgIdValid,
isSaving,
isSubmitting,
isValid,
onBack,
onFooterChange,
@@ -268,7 +292,9 @@ export function OrgSetupForm({
return;
}
void handleSubmit((data) => submitOrganizationSetup(data))(event);
void handleSubmit((data) =>
submitOrganizationSetup({ ...data, orgType: ORGANIZATION_TYPE.AWS }),
)(event);
};
useEffect(() => {
@@ -280,6 +306,11 @@ export function OrgSetupForm({
return (
<Form {...form}>
<SecretReplaceWarningModal
warning={replaceSecretWarning}
onConfirm={confirmSecretReplace}
onCancel={cancelSecretReplace}
/>
<form
id={formId}
onSubmit={handleFormSubmit}
@@ -312,7 +343,7 @@ export function OrgSetupForm({
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && isSubmitting && (
{setupPhase === ORG_SETUP_PHASE.ACCESS && isBusy && (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<Spinner className="size-6" />
@@ -329,6 +360,29 @@ export function OrgSetupForm({
</Alert>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
discoveryTimedOut &&
!isBusy && (
<DiscoveryTimeoutNotice
onKeepWaiting={() => void keepWaitingForDiscovery()}
onRetry={() => void retryDiscovery()}
/>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
discoveryFailed &&
!isBusy && (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => void retryDiscovery()}
>
Retry discovery
</Button>
)}
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
<div className="flex flex-col gap-4">
<WizardInputField
@@ -362,7 +416,7 @@ export function OrgSetupForm({
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isSubmitting && (
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isBusy && (
<div className="flex flex-col gap-8">
{/* External ID - shown first for both deployment steps */}
<div className="flex flex-col gap-4">
@@ -0,0 +1,43 @@
import type { FC } from "react";
import {
AWSProviderBadge,
GCPProviderBadge,
} from "@/components/icons/providers-badge";
import { getCandidateNoun } from "@/lib/organizations";
import type { IconSvgProps } from "@/types/components";
import { ORGANIZATION_TYPE, OrgFlowType } from "@/types/organizations";
/**
* The words come from the shared terminology table (`lib/organizations`), so
* wizard copy and the providers table cannot drift. This module only adds what
* the wizard needs on top: a capitalized plural and the badge to render.
*/
export interface OrgCandidateNoun {
singular: string;
plural: string;
/** Capitalized plural for headings (e.g. "Accounts Connected!"). */
Plural: string;
}
/** User-facing candidate noun: "project(s)" for GCP, "account(s)" for AWS. */
export function getOrgCandidateNoun(orgType: OrgFlowType): OrgCandidateNoun {
const { singular, plural } = getCandidateNoun(orgType);
return {
singular,
plural,
Plural: `${plural.charAt(0).toUpperCase()}${plural.slice(1)}`,
};
}
// `satisfies Record<OrgFlowType, …>`: a new onboarding flow is a compile error
// until it brings its own badge, instead of silently rendering the AWS one.
const ORG_PROVIDER_BADGE = {
[ORGANIZATION_TYPE.AWS]: AWSProviderBadge,
[ORGANIZATION_TYPE.GCP]: GCPProviderBadge,
} as const satisfies Record<OrgFlowType, FC<IconSvgProps>>;
export function getOrgProviderBadge(orgType: OrgFlowType): FC<IconSvgProps> {
return ORG_PROVIDER_BADGE[orgType];
}
@@ -0,0 +1,53 @@
"use client";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
interface SecretReplaceWarningModalProps {
warning: { providerCount: number } | null;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Shown when organization setup would overwrite an existing credential, which
* re-authenticates every provider already onboarded under the organization.
*/
export function SecretReplaceWarningModal({
warning,
onConfirm,
onCancel,
}: SecretReplaceWarningModalProps) {
const providerCount = warning?.providerCount ?? 0;
return (
<Modal
open={warning !== null}
onOpenChange={(open) => {
if (!open) onCancel();
}}
title="Replace existing credentials?"
description={
providerCount > 0
? `This organization already has credentials. Replacing them re-authenticates its ${providerCount} ${
providerCount === 1 ? "provider" : "providers"
}.`
: "This organization already has credentials. They will be replaced with the ones you just entered."
}
>
<div className="flex w-full justify-end gap-4">
<Button type="button" variant="ghost" size="lg" onClick={onCancel}>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
onClick={onConfirm}
>
Replace credentials
</Button>
</div>
</Modal>
);
}
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
import { NODE_KIND, ORGANIZATION_TYPE } from "@/types/organizations";
import {
PROVIDERS_GROUP_KIND,
PROVIDERS_ROW_TYPE,
@@ -126,6 +127,7 @@ const organizationRow: ProvidersTableRow = {
id: "org-1",
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
orgType: ORGANIZATION_TYPE.AWS,
name: "My AWS Organization",
externalId: "o-abc123def4",
parentExternalId: null,
@@ -139,6 +141,8 @@ const organizationalUnitRow: ProvidersTableRow = {
id: "ou-1",
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
orgType: ORGANIZATION_TYPE.AWS,
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Production OU",
externalId: "ou-abc123",
parentExternalId: "o-abc123def4",
@@ -1,5 +1,6 @@
"use client";
import { Info } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
@@ -14,6 +15,7 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { getFlowById } from "@/lib/onboarding";
import {
ADD_PROVIDER_SEARCH_PARAM,
@@ -29,7 +31,11 @@ import {
} from "@/lib/tours/use-driver-tour";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProvidersTableRow } from "@/types/providers-table";
import {
HIERARCHY_STATUS,
type HierarchyStatus,
type ProvidersTableRow,
} from "@/types/providers-table";
import type {
ScanConfigurationData,
ScanConfigurationListStatus,
@@ -65,6 +71,7 @@ interface ProvidersAccountsViewProps {
scanConfigs?: ScanConfigurationData[];
scanConfigStatus?: ScanConfigurationListStatus;
isScanLimitReached?: boolean;
hierarchyStatus?: HierarchyStatus;
}
export function ProvidersAccountsView({
@@ -78,6 +85,7 @@ export function ProvidersAccountsView({
scanConfigs,
scanConfigStatus,
isScanLimitReached,
hierarchyStatus = HIERARCHY_STATUS.AVAILABLE,
}: ProvidersAccountsViewProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -151,6 +159,15 @@ export function ProvidersAccountsView({
/>
) : (
<div className="flex flex-col gap-6">
{hierarchyStatus === HIERARCHY_STATUS.UNAVAILABLE && (
<Alert>
<Info />
<AlertDescription>
Organization grouping is incomplete. Some providers may appear
ungrouped.
</AlertDescription>
</Alert>
)}
<ProvidersFilters
filters={filters}
providers={providers}
@@ -14,9 +14,11 @@ import { DateWithTime, EntityInfo } from "@/components/shadcn/entities";
import { DataTableColumnHeader } from "@/components/shadcn/table";
import { DataTableExpandAllToggle } from "@/components/shadcn/table/data-table-expand-all-toggle";
import { DataTableExpandableCell } from "@/components/shadcn/table/data-table-expandable-cell";
import { getNodeLabel } from "@/lib/organizations";
import {
isProvidersOrganizationRow,
PROVIDERS_GROUP_KIND,
ProvidersGroupKind,
ProvidersProviderRow,
ProvidersTableRow,
} from "@/types/providers-table";
@@ -38,7 +40,7 @@ interface GroupNameChipsProps {
groupNames?: string[];
}
const OrganizationIcon = ({ groupKind }: { groupKind: string }) => {
const OrganizationIcon = ({ groupKind }: { groupKind: ProvidersGroupKind }) => {
const Icon =
groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION ? Building2 : FolderTree;
@@ -217,12 +219,15 @@ export function getColumnProviders(
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
// Node label comes from the terminology table: node `kind` decides,
// organization type is the fallback — never an ID prefix.
const label =
row.original.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION
? "Organization"
: getNodeLabel(row.original.orgType, row.original.kind);
return (
<span className="text-text-neutral-tertiary text-sm">
{row.original.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION
? "Organization"
: "Organizational Unit"}
</span>
<span className="text-text-neutral-tertiary text-sm">{label}</span>
);
}
@@ -3,22 +3,36 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
NODE_KIND,
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import {
PROVIDERS_GROUP_KIND,
PROVIDERS_ROW_TYPE,
ProvidersOrganizationRow,
ProvidersTableRow,
} from "@/types/providers-table";
import type { ScanConfigurationData } from "@/types/scan-configurations";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
() => ({
checkConnectionProviderMock: vi.fn(),
getScheduleMock: vi.fn(),
pushMock: vi.fn(),
}),
);
const {
checkConnectionProviderMock,
getScheduleMock,
getTasksByIdsMock,
pushMock,
revalidateProvidersMock,
startProviderConnectionChecksMock,
} = vi.hoisted(() => ({
checkConnectionProviderMock: vi.fn(),
getScheduleMock: vi.fn(),
getTasksByIdsMock: vi.fn(),
pushMock: vi.fn(),
revalidateProvidersMock: vi.fn(),
startProviderConnectionChecksMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
@@ -30,6 +44,12 @@ vi.mock("@/actions/organizations/organizations", () => ({
vi.mock("@/actions/providers/providers", () => ({
checkConnectionProvider: checkConnectionProviderMock,
revalidateProviders: revalidateProvidersMock,
startProviderConnectionChecks: startProviderConnectionChecksMock,
}));
vi.mock("@/actions/task/tasks", () => ({
getTasksByIds: getTasksByIdsMock,
}));
vi.mock("@/actions/schedules", () => ({
@@ -192,6 +212,7 @@ const createOrgRow = () =>
id: "org-1",
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
orgType: ORGANIZATION_TYPE.AWS,
name: "My AWS Organization",
externalId: "o-abc123def4",
parentExternalId: null,
@@ -225,6 +246,8 @@ const createOuRow = () =>
id: "ou-1",
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
orgType: ORGANIZATION_TYPE.AWS,
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "Production OU",
externalId: "ou-abc123",
parentExternalId: "o-abc123def4",
@@ -666,7 +689,8 @@ describe("DataTableRowActions", () => {
await user.click(screen.getByRole("button"));
expect(screen.getByText("Test Connections (1)")).toBeInTheDocument();
expect(screen.getByText("Delete Organization Unit")).toBeInTheDocument();
// Node action copy follows the node's kind.
expect(screen.getByText("Delete Organizational Unit")).toBeInTheDocument();
});
it("shows selected provider count in Test Connections when org row has active selection", async () => {
@@ -690,6 +714,57 @@ describe("DataTableRowActions", () => {
expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument();
});
it("tests every selected provider in one dispatch, not one call each", async () => {
// Given — Next's action queue serializes a per-provider loop, so the batch has
// to leave in a single action.
const user = userEvent.setup();
const testableProviderIds = ["provider-child-1", "provider-standalone"];
startProviderConnectionChecksMock.mockResolvedValue({
"provider-child-1": { taskId: "task-1" },
"provider-standalone": { taskId: "task-2" },
});
getTasksByIdsMock.mockResolvedValue({
"task-1": {
data: {
attributes: { state: "completed", result: { connected: true } },
},
},
"task-2": {
data: {
attributes: { state: "completed", result: { connected: true } },
},
},
});
render(
<DataTableRowActions
row={createOrgRow()}
hasSelection={true}
isRowSelected={false}
testableProviderIds={testableProviderIds}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("Test Connections (2)"));
// Then — one dispatch for the batch, one batched read, one revalidation.
await vi.waitFor(() =>
expect(revalidateProvidersMock).toHaveBeenCalledTimes(1),
);
expect(startProviderConnectionChecksMock).toHaveBeenCalledTimes(1);
expect(startProviderConnectionChecksMock).toHaveBeenCalledWith(
testableProviderIds,
);
expect(getTasksByIdsMock).toHaveBeenCalledTimes(1);
expect(getTasksByIdsMock).toHaveBeenCalledWith(["task-1", "task-2"]);
expect(checkConnectionProviderMock).not.toHaveBeenCalled();
});
it("shows selected provider count in Test Connections when OU row has active selection", async () => {
const user = userEvent.setup();
render(
@@ -831,6 +906,7 @@ describe("DataTableRowActions", () => {
// Then
expect(onOpenOrganizationWizard).toHaveBeenCalledWith({
organizationType: ORGANIZATION_TYPE.AWS,
organizationId: "org-1",
organizationName: "My AWS Organization",
externalId: "o-abc123def4",
@@ -839,4 +915,31 @@ describe("DataTableRowActions", () => {
intent: "edit-credentials",
});
});
it("hides Update Credentials for an organization type without an onboarding flow", async () => {
// Given: an organization type the wizard cannot onboard (display-only).
const user = userEvent.setup();
const row = createOrgRow();
(row.original as ProvidersOrganizationRow).orgType =
ORGANIZATION_TYPE.AZURE;
render(
<DataTableRowActions
row={row}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
// Then: the name edit stays (a plain PATCH), the wizard re-entry is gone.
expect(screen.getByText("Edit Organization Name")).toBeInTheDocument();
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
});
@@ -15,7 +15,12 @@ import { useState } from "react";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { updateProvider } from "@/actions/providers";
import {
revalidateProviders,
startProviderConnectionChecks,
} from "@/actions/providers/providers";
import { getSchedule } from "@/actions/schedules";
import { pollConnectionTasks } from "@/components/providers/organizations/org-account-selection.utils";
import {
ORG_WIZARD_INTENT,
OrgWizardInitialData,
@@ -33,11 +38,16 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { getNameSourceLabel, getNodeLabel } from "@/lib/organizations";
import { testProviderConnection } from "@/lib/provider-helpers";
import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
isOrgFlowType,
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
OrgFlowType,
} from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { isConfigurableProvider } from "@/types/providers";
import {
@@ -167,14 +177,23 @@ function OrgGroupDropdownActions({
const isOrgKind = rowData.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION;
const testIds = hasSelection ? testableProviderIds : childTestableIds;
const testCount = testIds.length;
const entityLabel = isOrgKind ? "organization" : "organizational unit";
const nodeLabel = getNodeLabel(rowData.orgType, rowData.kind);
const entityLabel = isOrgKind ? "organization" : nodeLabel.toLowerCase();
const nameSourceLabel = getNameSourceLabel(rowData.orgType);
// Credential updates re-enter the organization wizard, so this needs an
// organization type with an onboarding flow.
const orgFlowType: OrgFlowType | null = isOrgFlowType(rowData.orgType)
? rowData.orgType
: null;
const openOrgWizardAt = (
organizationType: OrgFlowType,
targetStep: OrgWizardInitialData["targetStep"],
targetPhase: OrgWizardInitialData["targetPhase"],
intent?: OrgWizardInitialData["intent"],
) => {
onOpenOrganizationWizard({
organizationType,
organizationId: rowData.id,
organizationName: rowData.name,
externalId: rowData.externalId ?? "",
@@ -196,7 +215,7 @@ function OrgGroupDropdownActions({
currentValue={rowData.name}
label="Name"
successMessage="The organization name was updated successfully."
helperText="If left blank, Prowler will use the name stored in AWS."
helperText={`If left blank, Prowler will use the name stored in ${nameSourceLabel}.`}
setIsOpen={setIsEditNameOpen}
onSave={(name) => updateOrganizationName(rowData.id, name)}
/>
@@ -206,12 +225,21 @@ function OrgGroupDropdownActions({
open={isDeleteOrgOpen}
onOpenChange={setIsDeleteOrgOpen}
title="Are you absolutely sure?"
description={`This action cannot be undone. This will permanently delete this ${entityLabel} and all associated data.`}
description={`This action cannot be undone. This will permanently delete this ${entityLabel}${
rowData.providerCount > 0
? ` and cascade to its ${rowData.providerCount} ${
rowData.providerCount === 1 ? "provider" : "providers"
}`
: ""
}.`}
>
<DeleteOrganizationForm
id={rowData.id}
name={rowData.name}
variant={rowData.groupKind}
orgType={rowData.orgType}
kind={rowData.kind}
providerCount={rowData.providerCount}
setIsOpen={setIsDeleteOrgOpen}
/>
</Modal>
@@ -225,17 +253,20 @@ function OrgGroupDropdownActions({
label="Edit Organization Name"
onSelect={() => setIsEditNameOpen(true)}
/>
<ActionDropdownItem
icon={<KeyRound />}
label="Update Credentials"
onSelect={() =>
openOrgWizardAt(
ORG_WIZARD_STEP.SETUP,
ORG_SETUP_PHASE.ACCESS,
ORG_WIZARD_INTENT.EDIT_CREDENTIALS,
)
}
/>
{orgFlowType && (
<ActionDropdownItem
icon={<KeyRound />}
label="Update Credentials"
onSelect={() =>
openOrgWizardAt(
orgFlowType,
ORG_WIZARD_STEP.SETUP,
ORG_SETUP_PHASE.ACCESS,
ORG_WIZARD_INTENT.EDIT_CREDENTIALS,
)
}
/>
)}
</>
)}
{isOrgKind && canEditSchedule && (
@@ -263,9 +294,7 @@ function OrgGroupDropdownActions({
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label={
isOrgKind ? "Delete Organization" : "Delete Organization Unit"
}
label={isOrgKind ? "Delete Organization" : `Delete ${nodeLabel}`}
destructive
onSelect={() => setIsDeleteOrgOpen(true)}
/>
@@ -346,16 +375,42 @@ export function DataTableRowActions({
if (ids.length === 0) return;
setLoading(true);
const results = await runWithConcurrencyLimit(ids, 10, async (id) => {
try {
return await testProviderConnection(id);
} catch {
return { connected: false, error: "Unexpected error" };
}
});
// Dispatched and polled in batches: client-invoked server actions run one at a
// time through Next's queue, so a loop here serializes whatever concurrency it
// asks for.
let succeeded = 0;
let failed = 0;
const pendingTaskIds: string[] = [];
const succeeded = results.filter((r) => r.connected).length;
const failed = results.length - succeeded;
try {
const outcomes = await startProviderConnectionChecks(ids);
for (const id of ids) {
const outcome = outcomes[id];
// No task id means nothing was ever tested, so it cannot count as passing.
if (!outcome || outcome.error || !outcome.taskId) {
failed += 1;
continue;
}
pendingTaskIds.push(outcome.taskId);
}
await pollConnectionTasks(pendingTaskIds, {
onSettled: (_taskId, result) => {
if (result.success) {
succeeded += 1;
} else {
failed += 1;
}
},
});
} catch {
failed = ids.length - succeeded;
}
await revalidateProviders();
if (failed === 0) {
toast({
@@ -366,7 +421,7 @@ export function DataTableRowActions({
toast({
variant: "destructive",
title: "Connection test completed",
description: `${succeeded} succeeded, ${failed} failed out of ${results.length} providers.`,
description: `${succeeded} succeeded, ${failed} failed out of ${ids.length} providers.`,
});
}
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ORG_WIZARD_STEP } from "@/types/organizations";
import { ORG_WIZARD_STEP, ORGANIZATION_TYPE } from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
@@ -202,6 +202,10 @@ describe("useProviderWizardController", () => {
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.isProviderFlow).toBe(false);
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP);
// The flow tags the store with the type it was opened for; AWS by default.
expect(useOrgSetupStore.getState().organizationType).toBe(
ORGANIZATION_TYPE.AWS,
);
expect(result.current.docsLink).toBe(
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
);
@@ -314,9 +318,10 @@ describe("useProviderWizardController", () => {
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("disc-1", {
roots: [],
organizational_units: [],
accounts: [],
orgType: ORGANIZATION_TYPE.AWS,
organization: { uid: "o-abc123def4", name: "My Org" },
nodes: [],
candidates: [],
});
});
@@ -12,6 +12,8 @@ import { useProviderWizardStore } from "@/store/provider-wizard/store";
import {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
ORGANIZATION_TYPE,
OrgFlowType,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
@@ -36,6 +38,11 @@ const WIZARD_VARIANT = {
type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT];
const ORG_DOCS_URL = {
[ORGANIZATION_TYPE.AWS]: DOCS_URLS.AWS_ORGANIZATIONS,
[ORGANIZATION_TYPE.GCP]: DOCS_URLS.GCP_ORGANIZATIONS,
} as const satisfies Record<OrgFlowType, string>;
const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
showBack: false,
backLabel: "Back",
@@ -100,7 +107,12 @@ export function useProviderWizardController({
mode,
providerType,
} = useProviderWizardStore();
const { reset: resetOrgWizard, setOrganization } = useOrgSetupStore();
const {
reset: resetOrgWizard,
setOrganization,
setOrganizationType,
organizationType,
} = useOrgSetupStore();
useEffect(() => {
if (!open) {
@@ -116,6 +128,7 @@ export function useProviderWizardController({
if (orgInitialData) {
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
resetOrgWizard();
setOrganizationType(orgInitialData.organizationType);
setOrganization(
orgInitialData.organizationId,
orgInitialData.organizationName,
@@ -174,6 +187,7 @@ export function useProviderWizardController({
resetProviderWizard,
setMode,
setOrganization,
setOrganizationType,
setProvider,
setSecretId,
setVia,
@@ -229,11 +243,14 @@ export function useProviderWizardController({
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
const openOrganizationsFlow = () => {
// AWS Organizations diverges from the credentials path the tour guides toward; end
const openOrganizationsFlow = (
orgType: OrgFlowType = ORGANIZATION_TYPE.AWS,
) => {
// Organizations diverges from the credentials path the tour guides toward; end
// it so it doesn't dangle on a step that no longer fits. No-op off-onboarding.
endActiveTour();
resetOrgWizard();
setOrganizationType(orgType);
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
@@ -253,7 +270,7 @@ export function useProviderWizardController({
const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER;
const docsLink = isProviderFlow
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
: DOCS_URLS.AWS_ORGANIZATIONS;
: ORG_DOCS_URL[organizationType];
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
const modalTitle = getProviderWizardModalTitle(mode);
@@ -269,6 +286,7 @@ export function useProviderWizardController({
mode,
modalTitle,
openOrganizationsFlow,
organizationType,
orgCurrentStep,
orgSetupPhase,
providerTypeHint,
@@ -2,6 +2,7 @@
import { ExternalLink, Info } from "lucide-react";
import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form";
import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection";
import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan";
import { OrgSetupForm } from "@/components/providers/organizations/org-setup-form";
@@ -11,7 +12,11 @@ import { Modal } from "@/components/shadcn/modal";
import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
@@ -69,6 +74,7 @@ export function ProviderWizardModal({
mode,
modalTitle,
openOrganizationsFlow,
organizationType,
orgCurrentStep,
orgSetupPhase,
resolvedFooterConfig,
@@ -212,7 +218,34 @@ export function ProviderWizardModal({
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
organizationType === ORGANIZATION_TYPE.GCP && (
<GcpOrgSetupForm
onBack={
isOrgDirectEntry ? handleClose : backToProviderFlow
}
onClose={handleClose}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
initialPhase={orgSetupPhase}
initialValues={
orgInitialData
? {
organizationName: orgInitialData.organizationName,
gcpOrgId: orgInitialData.externalId,
}
: undefined
}
intent={orgInitialData?.intent}
/>
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
organizationType !== ORGANIZATION_TYPE.GCP && (
<OrgSetupForm
onBack={
isOrgDirectEntry ? handleClose : backToProviderFlow
@@ -31,6 +31,7 @@ export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
export function getProviderWizardDocsDestination(docsLink: string) {
const destinationLabelMap: Record<string, string> = {
"aws-organizations": "AWS Organizations",
"gcp-organizations": "GCP Organizations",
aws: "AWS",
azure: "Azure",
m365: "Microsoft 365",
@@ -7,6 +7,7 @@ import {
ConnectAccountSuccessData,
} from "@/components/providers/workflow/forms";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { OrgFlowType } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
@@ -17,7 +18,7 @@ import {
interface ConnectStepProps {
onNext: () => void;
onSelectOrganizations: () => void;
onSelectOrganizations: (orgType: OrgFlowType) => void;
onFooterChange: (config: WizardFooterConfig) => void;
onProviderTypeChange: (providerType: ProviderType | null) => void;
}
+6 -1
View File
@@ -1,4 +1,8 @@
import { OrgSetupPhase, OrgWizardStep } from "@/types/organizations";
import {
OrgFlowType,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
import { ProviderWizardMode } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
@@ -22,6 +26,7 @@ export type OrgWizardIntent =
(typeof ORG_WIZARD_INTENT)[keyof typeof ORG_WIZARD_INTENT];
export interface OrgWizardInitialData {
organizationType: OrgFlowType;
organizationId: string;
organizationName: string;
externalId: string;
@@ -9,6 +9,7 @@ import { z } from "zod";
import { addProvider } from "@/actions/providers/providers";
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
import { Button, useToast } from "@/components/shadcn";
@@ -19,6 +20,7 @@ import {
KnownProviderType,
ProviderType,
} from "@/types";
import { ORGANIZATION_TYPE, OrgFlowType } from "@/types/organizations";
import { RadioGroupProvider } from "../../radio-group-provider";
@@ -31,9 +33,19 @@ export interface ConnectAccountSuccessData {
alias: string | null;
}
/** Provider types that offer an organization-onboarding method choice. */
function providerHasOrgMethod(
providerType: ProviderType | undefined,
): providerType is OrgFlowType {
return (
providerType === ORGANIZATION_TYPE.AWS ||
providerType === ORGANIZATION_TYPE.GCP
);
}
interface ConnectAccountFormProps {
onSuccess?: (data: ConnectAccountSuccessData) => void;
onSelectOrganizations?: () => void;
onSelectOrganizations?: (orgType: OrgFlowType) => void;
onProviderTypeChange?: (providerType: ProviderType | null) => void;
formId?: string;
hideNavigation?: boolean;
@@ -140,19 +152,27 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
function applyBackStep({
prevStep,
awsMethod,
method,
providerType,
form,
setPrevStep,
setAwsMethod,
setMethod,
}: {
prevStep: number;
awsMethod: "single" | null;
method: "single" | null;
providerType: ProviderType | undefined;
form: Pick<UseFormReturn<FormValues>, "setValue" | "clearErrors">;
setPrevStep: Dispatch<SetStateAction<number>>;
setAwsMethod: Dispatch<SetStateAction<"single" | null>>;
setMethod: Dispatch<SetStateAction<"single" | null>>;
}) {
if (prevStep === 2 && awsMethod === "single") {
setAwsMethod(null);
// With a method choice, "Back" from the single account/project form returns to
// the method selector rather than the provider picker.
if (
prevStep === 2 &&
method === "single" &&
providerHasOrgMethod(providerType)
) {
setMethod(null);
form.setValue("providerUid", "", { shouldValidate: false });
form.setValue("providerAlias", "", { shouldValidate: false });
return;
@@ -163,7 +183,7 @@ function applyBackStep({
form.setValue("providerType", undefined as unknown as KnownProviderType, {
shouldValidate: false,
});
setAwsMethod(null);
setMethod(null);
}
form.setValue("providerUid", "", { shouldValidate: false });
form.setValue("providerAlias", "", { shouldValidate: false });
@@ -181,7 +201,7 @@ export const ConnectAccountForm = ({
}: ConnectAccountFormProps) => {
const { toast } = useToast();
const [prevStep, setPrevStep] = useState(1);
const [awsMethod, setAwsMethod] = useState<"single" | null>(null);
const [method, setMethod] = useState<"single" | null>(null);
const router = useRouter();
const formSchema = addProviderFormSchema;
@@ -282,10 +302,11 @@ export const ConnectAccountForm = ({
const handleBackStep = () => {
applyBackStep({
prevStep,
awsMethod,
method,
providerType,
form,
setPrevStep,
setAwsMethod,
setMethod,
});
};
@@ -303,36 +324,39 @@ export const ConnectAccountForm = ({
onBackHandlerChange?.(() => {
applyBackStep({
prevStep,
awsMethod,
method,
providerType,
form,
setPrevStep,
setAwsMethod,
setMethod,
});
});
}, [onBackHandlerChange, prevStep, awsMethod, form]);
}, [onBackHandlerChange, prevStep, method, providerType, form]);
// Providers with a method choice reach the UID form only through "single".
const showUidForm =
!providerHasOrgMethod(providerType) || method === "single";
useEffect(() => {
const canSubmit =
prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") &&
showUidForm &&
providerUid.trim().length > 0 &&
form.formState.isValid;
onUiStateChange?.({
showBack: prevStep === 2,
showAction:
prevStep === 2 && (providerType !== "aws" || awsMethod === "single"),
showAction: prevStep === 2 && showUidForm,
actionLabel: "Next",
actionDisabled: !canSubmit || isLoading,
isLoading,
});
}, [
awsMethod,
showUidForm,
form.formState.isValid,
isLoading,
onUiStateChange,
prevStep,
providerType,
providerUid,
]);
@@ -353,45 +377,57 @@ export const ConnectAccountForm = ({
/>
</div>
)}
{/* Step 2: AWS method selector (only for AWS, before choosing method) */}
{prevStep === 2 && providerType === "aws" && awsMethod === null && (
{/* Step 2: AWS method selector (before choosing a method) */}
{prevStep === 2 && providerType === "aws" && method === null && (
<>
<ProviderTitleDocs providerType={providerType} />
<AwsMethodSelector
onSelectSingle={() => setAwsMethod("single")}
onSelectOrganizations={() => {
onSelectOrganizations?.();
}}
onSelectSingle={() => setMethod("single")}
onSelectOrganizations={() =>
onSelectOrganizations?.(ORGANIZATION_TYPE.AWS)
}
/>
</>
)}
{/* Step 2: GCP method selector (before choosing a method) */}
{prevStep === 2 && providerType === "gcp" && method === null && (
<>
<ProviderTitleDocs providerType={providerType} />
<GcpMethodSelector
onSelectSingle={() => setMethod("single")}
onSelectOrganizations={() =>
onSelectOrganizations?.(ORGANIZATION_TYPE.GCP)
}
/>
</>
)}
{/* Step 2: UID, alias form (providers without a method choice, or the
AWS/GCP single account/project method) */}
{prevStep === 2 && showUidForm && (
<>
<ProviderTitleDocs providerType={providerType} />
<WizardInputField
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<WizardInputField
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
/>
</>
)}
{/* Step 2: UID, alias form (non-AWS or AWS single account) */}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<>
<ProviderTitleDocs providerType={providerType} />
<WizardInputField
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<WizardInputField
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
/>
</>
)}
{!hideNavigation && (
<div className="flex w-full justify-end gap-4">
{prevStep === 2 && (
@@ -406,22 +442,21 @@ export const ConnectAccountForm = ({
Back
</Button>
)}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
{prevStep === 2 && showUidForm && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
</div>
)}
</form>
@@ -264,6 +264,40 @@ describe("EditScanScheduleModal remove flow", () => {
);
});
it("reports a bulk save no provider accepted instead of claiming success", async () => {
// Given — a 200 whose per-provider lists say nothing was committed.
const user = userEvent.setup();
updateSchedulesBulkMock.mockResolvedValue({
data: {
updated: [],
failed: [
{ id: "p1", error: "Denied" },
{ id: "p2", error: "Denied" },
],
},
});
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
providers={organizationProviders}
targetName="My AWS Organization"
targetId="o-abc123def4"
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Save" }));
// Then — the reason surfaces in the form and the modal stays open.
expect(
await screen.findByText("The scan schedule could not be saved: Denied."),
).toBeInTheDocument();
expect(toastMock).not.toHaveBeenCalled();
});
it("uses explicit provider ids for organization bulk schedules", async () => {
const user = userEvent.setup();
render(
@@ -24,14 +24,17 @@ import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
getScheduleFormValues,
isScheduleConfigured,
parseSchedulesBulkResult,
scheduleFormSchema,
} from "@/lib/schedules";
import type { ProviderType, ScheduleProps } from "@/types";
import type {
ScanScheduleProvider,
ScheduleFormValues,
SchedulesBulkResponse,
} from "@/types/schedules";
import { ScanScheduleFields } from "./scan-schedule-fields";
@@ -135,6 +138,34 @@ function EditScanScheduleForm({
return;
}
// A bulk save reports per-provider outcomes, so a 200 does not mean every
// provider was updated. Only the single-provider PATCH is flat.
const bulkOutcome = isBulk
? parseSchedulesBulkResult(result as SchedulesBulkResponse)
: null;
if (bulkOutcome && !bulkOutcome.isIndeterminate) {
const reasons = describeSchedulesBulkFailures(bulkOutcome.failures);
if (bulkOutcome.updatedProviderIds.length === 0) {
form.setError("root", {
message: `The scan schedule could not be saved${reasons ? `: ${reasons}.` : "."}`,
});
return;
}
if (bulkOutcome.failures.length > 0) {
toast({
title: "Scan schedule partially saved",
description: `Updated ${bulkOutcome.updatedProviderIds.length} of ${targetProviderIds.length} providers${reasons ? `: ${reasons}.` : "."}`,
});
onSaved?.();
onClose();
router.refresh();
return;
}
}
toast({
title: "Scan schedule saved",
description: isBulk
+12 -1
View File
@@ -191,4 +191,15 @@ function useToast() {
};
}
export { toast, useToast };
/**
* Clears the module-level store. Tests need this: toasts stay in state for
* `TOAST_REMOVE_DELAY` (~17 min), so one raised by an earlier test re-renders on
* the next mount and its fixed-position host swallows clicks meant for the page.
* Dismissing is not enough that only marks them closed.
*/
function resetToasts() {
memoryState = { toasts: [] };
listeners.forEach((listener) => listener(memoryState));
}
export { resetToasts, toast, useToast };
+14 -11
View File
@@ -90,17 +90,20 @@ export function TreeLeaf({
/>
)}
{renderItem ? (
renderItem({
item,
level,
isLeaf: true,
isSelected,
hasChildren: false,
})
) : (
<TreeItemLabel item={item} />
)}
{/* Same shrink wrapper as TreeNode, or a long id overruns its neighbours. */}
<div className="min-w-0 flex-1">
{renderItem ? (
renderItem({
item,
level,
isLeaf: true,
isSelected,
hasChildren: false,
})
) : (
<TreeItemLabel item={item} />
)}
</div>
</div>
);
}
+12 -4
View File
@@ -63,10 +63,15 @@ export function TreeNode({
};
const handleSelect = () => {
onSelectionChange(item.id, item);
if (!item.disabled) {
onSelectionChange(item.id, item);
}
};
const handleContentClick = showCheckboxes ? handleSelect : handleToggleExpand;
// A disabled node still expands on click: it is the only way to look inside a
// container that offers nothing to select.
const handleContentClick =
showCheckboxes && !item.disabled ? handleSelect : handleToggleExpand;
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
@@ -97,12 +102,15 @@ export function TreeNode({
"flex items-center gap-2 rounded-md px-2 py-1.5",
"cursor-pointer hover:bg-white/5",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
item.disabled && "cursor-not-allowed opacity-50",
// No `cursor-not-allowed`: unlike a disabled leaf, the row still expands.
item.disabled && "opacity-50",
item.className,
)}
style={{ paddingLeft: getTreeNodePadding(level) }}
role="treeitem"
tabIndex={item.disabled ? -1 : 0}
// Focusable even when disabled, or a keyboard user could not expand it;
// `aria-disabled` below is what states the row cannot be selected.
tabIndex={0}
aria-expanded={isExpanded}
aria-selected={isSelected}
aria-disabled={item.disabled}
+2
View File
@@ -21,6 +21,7 @@ describe("cloud upgrade content", () => {
"Keep Every Provider Checked Automatically",
"Turn Findings into Alerts",
"Add Your Entire AWS Organization",
"Add Your Entire GCP Organization",
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
@@ -72,6 +73,7 @@ describe("cloud upgrade URLs", () => {
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
[CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS, "gcp-organization"],
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
+12
View File
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
[CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS]: "gcp-organization",
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
@@ -66,6 +67,17 @@ export const CLOUD_UPGRADE_CONTENT = {
],
primaryCta: "Set Up AWS Organizations in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS]: {
title: "Add Your Entire GCP Organization",
description:
"Discover folders and projects, then manage them from one place.",
benefits: [
"Discover folders and projects automatically",
"Choose exactly which projects to onboard",
"Apply schedules across the selected projects",
],
primaryCta: "Set Up GCP Organizations in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT]: {
title: "Bring CLI Findings into One Cloud View",
description:
+2
View File
@@ -10,6 +10,8 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage",
AWS_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
GCP_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-gcp-organizations",
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
SCAN_CONFIGURATION:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration",
+11 -3
View File
@@ -351,11 +351,15 @@ export const isGithubOAuthEnabled =
!!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_ID &&
!!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_SECRET;
/**
* Polls a task until it settles. The settled task comes back with the verdict so
* callers can read its result without fetching the same task again.
*/
export const checkTaskStatus = async (
taskId: string,
maxRetries: number = 20,
retryDelay: number = 1500,
): Promise<{ completed: boolean; error?: string }> => {
): Promise<{ completed: boolean; error?: string; task?: any }> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const task = await getTask(taskId);
@@ -368,9 +372,13 @@ export const checkTaskStatus = async (
switch (state) {
case "completed":
return { completed: true };
return { completed: true, task };
case "failed":
return { completed: false, error: task.data.attributes.result.error };
return {
completed: false,
error: task.data.attributes.result.error,
task,
};
case "available":
case "scheduled":
case "executing":
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
NODE_KIND,
NodeKind,
ORGANIZATION_TYPE,
OrganizationType,
} from "@/types/organizations";
import {
getCandidateNoun,
getNameSourceLabel,
getNodeLabel,
toNodeKind,
} from "./organizations";
describe("getNodeLabel", () => {
it("labels a node by its kind, regardless of organization type", () => {
expect(
getNodeLabel(ORGANIZATION_TYPE.AWS, NODE_KIND.ORGANIZATIONAL_UNIT),
).toBe("Organizational Unit");
expect(getNodeLabel(ORGANIZATION_TYPE.GCP, NODE_KIND.FOLDER)).toBe(
"Folder",
);
});
it("falls back to the organization type's container label when kind is absent", () => {
// Nodes served before the canonical `kind` attribute exists, or organization
// rows that have no kind at all.
expect(getNodeLabel(ORGANIZATION_TYPE.AWS)).toBe("Organizational Unit");
expect(getNodeLabel(ORGANIZATION_TYPE.GCP)).toBe("Folder");
});
it("uses the organization type's own vocabulary for types without an onboarding flow", () => {
// Display covers every organization type the API can report; an Azure
// organization must never inherit AWS wording.
expect(getNodeLabel(ORGANIZATION_TYPE.AZURE)).toBe("Management Group");
});
it("falls back to neutral wording for an organization type this build predates", () => {
// The enum mirrors a server-side one: an unknown value must render neutrally
// instead of crashing the cell or claiming AWS.
expect(getNodeLabel("oci" as OrganizationType)).toBe("Group");
expect(getNameSourceLabel("oci" as OrganizationType)).toBe(
"the cloud provider",
);
});
it("falls back to the container label for a node kind this build predates", () => {
// `kind` is typed but unvalidated: node rows pass the wire attribute
// straight through, so a backend-added kind must resolve to the
// organization's own container label. Returning `undefined` would crash
// callers that lowercase the result (the deletion dialog).
const unknownKind = "management-group" as NodeKind;
expect(getNodeLabel(ORGANIZATION_TYPE.AWS, unknownKind)).toBe(
"Organizational Unit",
);
expect(getNodeLabel(ORGANIZATION_TYPE.GCP, unknownKind)).toBe("Folder");
expect(getNodeLabel("oci" as OrganizationType, unknownKind)).toBe("Group");
});
it("falls back for wire values that collide with Object.prototype keys", () => {
// The lookup tables are object literals, so indexing them with an inherited
// key returns a truthy non-string — a function, or the prototype itself.
// That defeats a `??` fallback and hands the callers something they then
// call `.toLowerCase()` on. Every label getter must survive it.
for (const key of [
"constructor",
"toString",
"valueOf",
"hasOwnProperty",
"__proto__",
]) {
expect(getNodeLabel(ORGANIZATION_TYPE.AWS, key as NodeKind)).toBe(
"Organizational Unit",
);
expect(getNodeLabel(key as OrganizationType, "folder" as NodeKind)).toBe(
"Folder",
);
expect(getNodeLabel(key as OrganizationType)).toBe("Group");
expect(getNameSourceLabel(key as OrganizationType)).toBe(
"the cloud provider",
);
expect(getCandidateNoun(key as OrganizationType)).toEqual({
singular: "account",
plural: "accounts",
});
}
});
});
describe("getNameSourceLabel", () => {
it("names the provider-side source of the organization name", () => {
expect(getNameSourceLabel(ORGANIZATION_TYPE.AWS)).toBe("AWS");
expect(getNameSourceLabel(ORGANIZATION_TYPE.GCP)).toBe("Google Cloud");
expect(getNameSourceLabel(ORGANIZATION_TYPE.AZURE)).toBe("Azure");
});
});
describe("getCandidateNoun", () => {
it("names what a discovered candidate is, per organization type", () => {
expect(getCandidateNoun(ORGANIZATION_TYPE.AWS)).toEqual({
singular: "account",
plural: "accounts",
});
expect(getCandidateNoun(ORGANIZATION_TYPE.GCP)).toEqual({
singular: "project",
plural: "projects",
});
});
});
describe("toNodeKind", () => {
it("narrows canonical kind values", () => {
expect(toNodeKind("organizational-unit")).toBe(
NODE_KIND.ORGANIZATIONAL_UNIT,
);
expect(toNodeKind("folder")).toBe(NODE_KIND.FOLDER);
});
it("returns undefined for absent or unknown kinds", () => {
expect(toNodeKind(undefined)).toBeUndefined();
expect(toNodeKind("")).toBeUndefined();
expect(toNodeKind("organizational_unit")).toBeUndefined();
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Organization vocabulary, keyed by organization type and node kind.
*
* Every hierarchy surface (providers table, row actions, deletion dialogs,
* onboarding copy) reads its wording from here instead of branching on a
* per-provider boolean. The table is typed `satisfies Record<OrganizationType,
* >`, so adding an organization type is a compile error until its vocabulary
* exists a new type can never silently inherit AWS wording.
*/
import {
NODE_KIND,
NodeKind,
ORGANIZATION_TYPE,
OrganizationType,
} from "@/types/organizations";
interface CandidateNoun {
singular: string;
plural: string;
}
interface OrgTypeTerminology {
/** Hierarchy container label, used when a node carries no `kind`. */
containerLabel: string;
/** Where the provider-side organization/node name comes from. */
nameSourceLabel: string;
/** What a discovered candidate is called in the onboarding flow. */
candidateNoun: CandidateNoun;
}
const ORGANIZATION_TERMINOLOGY = {
[ORGANIZATION_TYPE.AWS]: {
containerLabel: "Organizational Unit",
nameSourceLabel: "AWS",
candidateNoun: { singular: "account", plural: "accounts" },
},
[ORGANIZATION_TYPE.AZURE]: {
containerLabel: "Management Group",
nameSourceLabel: "Azure",
candidateNoun: { singular: "subscription", plural: "subscriptions" },
},
[ORGANIZATION_TYPE.GCP]: {
containerLabel: "Folder",
nameSourceLabel: "Google Cloud",
candidateNoun: { singular: "project", plural: "projects" },
},
} as const satisfies Record<OrganizationType, OrgTypeTerminology>;
const NODE_KIND_LABEL = {
[NODE_KIND.ORGANIZATIONAL_UNIT]: "Organizational Unit",
[NODE_KIND.FOLDER]: "Folder",
} as const satisfies Record<NodeKind, string>;
const NODE_KINDS: readonly string[] = Object.values(NODE_KIND);
/**
* The organization-type enum mirrors a server-side one, so a type this build
* doesn't know about can still arrive on the wire. Rendering neutral wording
* beats crashing a table cell or claiming AWS.
*/
const NEUTRAL_TERMINOLOGY: OrgTypeTerminology = {
containerLabel: "Group",
nameSourceLabel: "the cloud provider",
candidateNoun: { singular: "account", plural: "accounts" },
};
const ORGANIZATION_TYPES: readonly string[] = Object.values(ORGANIZATION_TYPE);
// Membership check, not a `??` on the lookup: the tables are object literals, so
// an inherited key ("toString") would resolve to a truthy non-string.
function terminologyFor(orgType: OrganizationType): OrgTypeTerminology {
return ORGANIZATION_TYPES.includes(orgType)
? ORGANIZATION_TERMINOLOGY[orgType]
: NEUTRAL_TERMINOLOGY;
}
/**
* Container label for a hierarchy node. `kind` decides when present (canonical
* contract); the organization type is the fallback. Never derived from ID
* prefixes.
*/
export function getNodeLabel(
orgType: OrganizationType,
kind?: NodeKind,
): string {
// `kind` is typed but unvalidated: node rows pass the wire attribute through.
const knownKind = toNodeKind(kind);
return knownKind
? NODE_KIND_LABEL[knownKind]
: terminologyFor(orgType).containerLabel;
}
/** Provider-side source of the organization name (edit-name helper copy). */
export function getNameSourceLabel(orgType: OrganizationType): string {
return terminologyFor(orgType).nameSourceLabel;
}
/**
* What discovered candidates are called for this organization type.
*/
export function getCandidateNoun(orgType: OrganizationType): CandidateNoun {
return terminologyFor(orgType).candidateNoun;
}
/**
* Narrows a tree item's opaque `kind` string (the generic `TreeDataItem` carries
* no organization types) to a canonical node kind.
*/
export function toNodeKind(kind?: string): NodeKind | undefined {
return kind && NODE_KINDS.includes(kind) ? (kind as NodeKind) : undefined;
}
+8 -5
View File
@@ -1,5 +1,4 @@
import { checkConnectionProvider } from "@/actions/providers/providers";
import { getTask } from "@/actions/task/tasks";
import {
ProviderEntity,
ProviderProps,
@@ -181,7 +180,8 @@ export interface TestConnectionResult {
* Tests a provider's connection end-to-end: submits the task, polls until
* completion, and returns the real connection result.
*
* Used by both the Provider Wizard (single) and bulk test (via concurrency limit).
* Single-provider paths only (the wizard and the table's per-row action); a batch
* goes through `startProviderConnectionChecks`, which fans out server-side.
*/
export async function testProviderConnection(
providerId: string,
@@ -212,11 +212,14 @@ export async function testProviderConnection(
};
}
const task = await getTask(taskId);
const { connected, error } = task.data.attributes.result;
// Read from the task the poller already fetched. A completed task with no
// readable `connected` counts as connected, as in the batched poller.
const result = taskResult.task?.data?.attributes?.result;
const connected =
typeof result?.connected === "boolean" ? result.connected : true;
return {
connected,
error: connected ? null : error || "Unknown error",
error: connected ? null : result?.error || "Unknown error",
};
}
+92
View File
@@ -5,6 +5,7 @@ import {
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
@@ -13,6 +14,7 @@ import {
getScheduleFormDefaults,
getScheduleFormValues,
isScheduleConfigured,
parseSchedulesBulkResult,
} from "@/lib/schedules";
import {
SCAN_SCHEDULE_CAPABILITY,
@@ -568,3 +570,93 @@ describe("buildProviderScheduleSummary", () => {
expect(summary.lastScanAt).toBe("2026-06-08T07:00:00Z");
});
});
describe("parseSchedulesBulkResult", () => {
it("reads the lists the API actually returns, directly under `data`", () => {
// Given — the real body: a plain dict the JSON:API renderer wraps in `data`,
// with no `attributes` level.
const result = {
data: {
updated: ["provider-1", "provider-2"],
failed: [{ id: "provider-3", error: "Denied" }],
},
};
// When
const outcome = parseSchedulesBulkResult(result);
// Then
expect(outcome.updatedProviderIds).toEqual(["provider-1", "provider-2"]);
expect(outcome.failures).toEqual([{ id: "provider-3", error: "Denied" }]);
expect(outcome.isIndeterminate).toBe(false);
});
it("still reads the lists if the endpoint is ever rendered through its serializer", () => {
// Given — the documented (but not implemented) JSON:API resource shape.
const result = {
data: {
type: "schedules-bulk" as const,
attributes: { updated: ["provider-1"], failed: [] },
},
};
// When / Then — tolerated, so a server-side normalization cannot read as
// "nothing was updated".
expect(parseSchedulesBulkResult(result).updatedProviderIds).toEqual([
"provider-1",
]);
expect(parseSchedulesBulkResult(result).isIndeterminate).toBe(false);
});
it("reports a body carrying neither list as indeterminate, not as failure", () => {
// Given — an empty/204 body, which `handleApiResponse` turns into this.
// When / Then — the POST commits before answering, so this is not "nothing
// was saved".
expect(parseSchedulesBulkResult({ success: true }).isIndeterminate).toBe(
true,
);
expect(parseSchedulesBulkResult({ data: {} }).isIndeterminate).toBe(true);
});
it("drops entries that cannot be used", () => {
// Given — ids that are not strings and failures without a usable id.
const result = {
data: {
updated: ["provider-1", 42, null] as unknown as string[],
failed: [
{ id: "provider-2", error: "Denied" },
{ error: "no id" },
] as unknown as Array<{ id: string; error: string }>,
},
};
// When
const outcome = parseSchedulesBulkResult(result);
// Then
expect(outcome.updatedProviderIds).toEqual(["provider-1"]);
expect(outcome.failures).toEqual([{ id: "provider-2", error: "Denied" }]);
expect(outcome.isIndeterminate).toBe(false);
});
});
describe("describeSchedulesBulkFailures", () => {
it("deduplicates repeated reasons and caps how many it names", () => {
const summary = describeSchedulesBulkFailures(
[
{ id: "p1", error: "Denied" },
{ id: "p2", error: "Denied" },
{ id: "p3", error: "Payment required" },
{ id: "p4", error: "Not connected" },
],
2,
);
expect(summary).toBe("Denied; Payment required");
});
it("returns an empty string when there is nothing to explain", () => {
expect(describeSchedulesBulkFailures([])).toBe("");
expect(describeSchedulesBulkFailures([{ id: "p1", error: " " }])).toBe("");
});
});
+55
View File
@@ -9,6 +9,8 @@ import {
type ScheduleAttributes,
type ScheduleFormValues,
type ScheduleProps,
type SchedulesBulkFailure,
type SchedulesBulkResponse,
type ScheduleUpdatePayload,
} from "@/types/schedules";
@@ -146,6 +148,59 @@ export function buildScheduleUpdatePayload(
};
}
export interface SchedulesBulkOutcome {
updatedProviderIds: string[];
failures: SchedulesBulkFailure[];
/**
* Neither list was present. `/schedules/bulk` commits each provider before it
* answers, so this means "saved, shape unreadable" never "nothing was saved".
*/
isIndeterminate: boolean;
}
/**
* Single reader for the `/schedules/bulk` body. The API returns the lists directly
* under `data`; the `data.attributes` form is tolerated only so a future
* server-side JSON:API normalization cannot read as an empty result.
*/
export function parseSchedulesBulkResult(
result: SchedulesBulkResponse,
): SchedulesBulkOutcome {
const lists = result.data ?? {};
const rawUpdated = lists.updated ?? lists.attributes?.updated;
const rawFailed = lists.failed ?? lists.attributes?.failed;
const updatedProviderIds = Array.isArray(rawUpdated)
? rawUpdated.filter((id): id is string => typeof id === "string")
: [];
const failures = Array.isArray(rawFailed)
? rawFailed
.filter(
(failure): failure is SchedulesBulkFailure =>
typeof failure?.id === "string",
)
.map((failure) => ({ id: failure.id, error: String(failure.error) }))
: [];
return {
updatedProviderIds,
failures,
isIndeterminate: !Array.isArray(rawUpdated) && !Array.isArray(rawFailed),
};
}
/** Deduplicated, capped reason summary for a failure toast or form error. */
export function describeSchedulesBulkFailures(
failures: SchedulesBulkFailure[],
limit = 2,
): string {
const reasons = Array.from(
new Set(failures.map((failure) => failure.error?.trim()).filter(Boolean)),
);
return reasons.slice(0, limit).join("; ");
}
interface SchedulesActionResult {
data?: ScheduleProps[] | null;
error?: unknown;
+2 -2
View File
@@ -20,8 +20,6 @@
"start": "next start",
"start:standalone": "node .next/standalone/server.js",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:browser:watch": "vitest --project browser",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation",
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --debug",
@@ -29,6 +27,8 @@
"test:e2e:install": "playwright install",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --ui",
"test:integration": "vitest run --project integration",
"test:integration:watch": "vitest --project integration",
"test:unit": "vitest run --project unit",
"test:watch": "vitest",
"tour:check": "node scripts/check-tour-alignment.mjs",
+55 -6
View File
@@ -1,7 +1,30 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
AwsOrgHierarchy,
NODE_KIND,
ORGANIZATION_TYPE,
} from "@/types/organizations";
import { useOrgSetupStore } from "./store";
const hierarchy: AwsOrgHierarchy = {
orgType: ORGANIZATION_TYPE.AWS,
organization: { uid: "o-abc123def4", name: "My Org" },
nodes: [
{
id: "ou-1",
kind: NODE_KIND.ORGANIZATIONAL_UNIT,
name: "OU One",
parentId: "r-root",
},
],
candidates: [
{ uid: "111111111111", label: "App", parentId: "ou-1" },
{ uid: "222222222222", label: "Security", parentId: "ou-1" },
],
};
describe("useOrgSetupStore", () => {
beforeEach(() => {
sessionStorage.clear();
@@ -14,14 +37,10 @@ describe("useOrgSetupStore", () => {
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("discovery-1", {
roots: [],
organizational_units: [],
accounts: [],
});
useOrgSetupStore.getState().setDiscovery("discovery-1", hierarchy);
useOrgSetupStore
.getState()
.setSelectedAccountIds(["111111111111", "222222222222"]);
.setSelectedCandidateIds(["111111111111", "222222222222"]);
// When
const persistedValue = sessionStorage.getItem("org-setup-store");
@@ -30,4 +49,34 @@ describe("useOrgSetupStore", () => {
expect(persistedValue).toBeTruthy();
expect(localStorage.getItem("org-setup-store")).toBeNull();
});
it.each([
["an organization type with no onboarding flow", ORGANIZATION_TYPE.AZURE],
["a prototype key", "__proto__"],
["a non-string", 42],
])("discards %s rehydrated as the organization type", (_label, stored) => {
// Given — sessionStorage is untrusted and this slot is a discriminant. The
// version comes from the store so a bump cannot make this pass vacuously.
sessionStorage.setItem(
"org-setup-store",
JSON.stringify({
state: {
organizationType: stored,
organizationId: "org-9",
selectedCandidateIds: [],
},
version: useOrgSetupStore.persist.getOptions().version,
}),
);
// When
useOrgSetupStore.persist.rehydrate();
// Then — the snapshot was read (so this is not passing by rehydration
// silently not happening), but the bad discriminant did not survive it.
expect(useOrgSetupStore.getState().organizationId).toBe("org-9");
expect(useOrgSetupStore.getState().organizationType).toBe(
ORGANIZATION_TYPE.AWS,
);
});
});
+85 -51
View File
@@ -2,62 +2,70 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
buildAccountLookup,
buildCandidateLookup,
buildOrgTreeData,
getSelectableAccountIds,
getSelectableCandidateIds,
} from "@/actions/organizations/organizations.adapter";
import {
ConnectionTestStatus,
DiscoveredAccount,
DiscoveryResult,
DiscoveryStatus,
OrgCandidate,
OrgFlowType,
OrgHierarchy,
ORGANIZATION_TYPE,
toOrgFlowType,
} from "@/types/organizations";
import { TreeDataItem } from "@/types/tree";
interface DerivedDiscoveryState {
treeData: TreeDataItem[];
accountLookup: Map<string, DiscoveredAccount>;
selectableAccountIds: string[];
selectableAccountIdSet: Set<string>;
candidateLookup: Map<string, OrgCandidate>;
selectableCandidateIds: string[];
selectableCandidateIdSet: Set<string>;
}
function buildDerivedDiscoveryState(
discoveryResult: DiscoveryResult | null,
hierarchy: OrgHierarchy | null,
): DerivedDiscoveryState {
if (!discoveryResult) {
if (!hierarchy) {
return {
treeData: [],
accountLookup: new Map<string, DiscoveredAccount>(),
selectableAccountIds: [],
selectableAccountIdSet: new Set<string>(),
candidateLookup: new Map<string, OrgCandidate>(),
selectableCandidateIds: [],
selectableCandidateIdSet: new Set<string>(),
};
}
const selectableAccountIds = getSelectableAccountIds(discoveryResult);
const selectableCandidateIds = getSelectableCandidateIds(hierarchy);
return {
treeData: buildOrgTreeData(discoveryResult),
accountLookup: buildAccountLookup(discoveryResult),
selectableAccountIds,
selectableAccountIdSet: new Set(selectableAccountIds),
treeData: buildOrgTreeData(hierarchy),
candidateLookup: buildCandidateLookup(hierarchy),
selectableCandidateIds,
selectableCandidateIdSet: new Set(selectableCandidateIds),
};
}
interface OrgSetupState {
// Discriminant.
organizationType: OrgFlowType;
// Identity
organizationId: string | null;
organizationName: string | null;
organizationExternalId: string | null;
discoveryId: string | null;
// Discovery
discoveryResult: DiscoveryResult | null;
discoveryId: string | null;
discoveryStatus: DiscoveryStatus | null;
hierarchy: OrgHierarchy | null;
treeData: TreeDataItem[];
accountLookup: Map<string, DiscoveredAccount>;
selectableAccountIds: string[];
selectableAccountIdSet: Set<string>;
candidateLookup: Map<string, OrgCandidate>;
selectableCandidateIds: string[];
selectableCandidateIdSet: Set<string>;
// Selection + aliases
selectedAccountIds: string[];
accountAliases: Record<string, string>;
selectedCandidateIds: string[];
candidateAliases: Record<string, string>;
// Apply result
createdProviderIds: string[];
@@ -67,10 +75,14 @@ interface OrgSetupState {
connectionErrors: Record<string, string>;
// Actions
setOrganizationType: (organizationType: OrgFlowType) => void;
setOrganization: (id: string, name: string, externalId: string) => void;
setDiscovery: (id: string, result: DiscoveryResult) => void;
setSelectedAccountIds: (ids: string[]) => void;
setAccountAlias: (accountId: string, alias: string) => void;
// Persists the discovery id + status at trigger time so an interrupted
// discovery can be resumed on wizard re-entry (resume read is Phase 2).
setDiscoveryTriggered: (discoveryId: string) => void;
setDiscovery: (id: string, hierarchy: OrgHierarchy) => void;
setSelectedCandidateIds: (ids: string[]) => void;
setCandidateAlias: (candidateId: string, alias: string) => void;
setCreatedProviderIds: (ids: string[]) => void;
clearValidationState: () => void;
setConnectionError: (providerId: string, error: string | null) => void;
@@ -82,17 +94,19 @@ interface OrgSetupState {
}
const initialState = {
organizationType: ORGANIZATION_TYPE.AWS as OrgFlowType,
organizationId: null,
organizationName: null,
organizationExternalId: null,
discoveryId: null,
discoveryResult: null,
discoveryStatus: null,
hierarchy: null,
treeData: [],
accountLookup: new Map<string, DiscoveredAccount>(),
selectableAccountIds: [],
selectableAccountIdSet: new Set<string>(),
selectedAccountIds: [],
accountAliases: {},
candidateLookup: new Map<string, OrgCandidate>(),
selectableCandidateIds: [],
selectableCandidateIdSet: new Set<string>(),
selectedCandidateIds: [],
candidateAliases: {},
createdProviderIds: [],
connectionResults: {},
connectionErrors: {},
@@ -103,6 +117,8 @@ export const useOrgSetupStore = create<OrgSetupState>()(
(set) => ({
...initialState,
setOrganizationType: (organizationType) => set({ organizationType }),
setOrganization: (id, name, externalId) =>
set({
organizationId: id,
@@ -110,29 +126,34 @@ export const useOrgSetupStore = create<OrgSetupState>()(
organizationExternalId: externalId,
}),
setDiscovery: (id, result) =>
setDiscoveryTriggered: (discoveryId) =>
set({ discoveryId, discoveryStatus: "pending" }),
setDiscovery: (id, hierarchy) =>
set((state) => {
const derivedState = buildDerivedDiscoveryState(result);
const derivedState = buildDerivedDiscoveryState(hierarchy);
return {
discoveryId: id,
discoveryResult: result,
discoveryStatus: "succeeded",
hierarchy,
...derivedState,
selectedAccountIds: state.selectedAccountIds.filter((accountId) =>
derivedState.selectableAccountIdSet.has(accountId),
selectedCandidateIds: state.selectedCandidateIds.filter(
(candidateId) =>
derivedState.selectableCandidateIdSet.has(candidateId),
),
};
}),
setSelectedAccountIds: (ids) =>
setSelectedCandidateIds: (ids) =>
set((state) => ({
selectedAccountIds: ids.filter((accountId) =>
state.selectableAccountIdSet.has(accountId),
selectedCandidateIds: ids.filter((candidateId) =>
state.selectableCandidateIdSet.has(candidateId),
),
})),
setAccountAlias: (accountId, alias) =>
setCandidateAlias: (candidateId, alias) =>
set((state) => ({
accountAliases: { ...state.accountAliases, [accountId]: alias },
candidateAliases: { ...state.candidateAliases, [candidateId]: alias },
})),
setCreatedProviderIds: (ids) => set({ createdProviderIds: ids }),
@@ -171,32 +192,45 @@ export const useOrgSetupStore = create<OrgSetupState>()(
}),
{
name: "org-setup-store",
// Deliberately migration-free: a snapshot from the previous version is
// discarded, resetting an onboarding session that was in flight when the
// release landed. The persisted shape changed (normalized hierarchy,
// organization type, discovery-resume fields) and this is per-tab
// sessionStorage holding only wizard progress, so re-entering the flow is
// cheaper and safer than migrating a shape we removed. Bumped to 2 to also
// discard GCP hierarchies normalized by the pre-fix mapper, whose folder ids
// came from a field the wire never had and cannot nest.
version: 2,
storage: createJSONStorage(() => sessionStorage),
merge: (persistedState, currentState) => {
const mergedState = {
...currentState,
...(persistedState as Partial<OrgSetupState>),
};
const derivedState = buildDerivedDiscoveryState(
mergedState.discoveryResult,
);
const derivedState = buildDerivedDiscoveryState(mergedState.hierarchy);
return {
...mergedState,
...derivedState,
selectedAccountIds: mergedState.selectedAccountIds.filter(
(accountId) => derivedState.selectableAccountIdSet.has(accountId),
organizationType:
toOrgFlowType(mergedState.organizationType) ??
currentState.organizationType,
selectedCandidateIds: mergedState.selectedCandidateIds.filter(
(candidateId) =>
derivedState.selectableCandidateIdSet.has(candidateId),
),
};
},
partialize: (state) => ({
organizationType: state.organizationType,
organizationId: state.organizationId,
organizationName: state.organizationName,
organizationExternalId: state.organizationExternalId,
discoveryId: state.discoveryId,
discoveryResult: state.discoveryResult,
selectedAccountIds: state.selectedAccountIds,
accountAliases: state.accountAliases,
discoveryStatus: state.discoveryStatus,
hierarchy: state.hierarchy,
selectedCandidateIds: state.selectedCandidateIds,
candidateAliases: state.candidateAliases,
}),
},
),
+1
View File
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
GCP_ORGANIZATIONS: "gcp_organizations",
JIRA_DISPATCH: "jira_dispatch",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
+280 -50
View File
@@ -25,23 +25,38 @@ export const ORG_RELATION = {
export type OrgRelation = (typeof ORG_RELATION)[keyof typeof ORG_RELATION];
export const OU_RELATION = {
export const NODE_RELATION = {
NOT_APPLICABLE: "not_applicable",
ALREADY_LINKED: "already_linked",
LINK_REQUIRED: "link_required",
LINKED_TO_OTHER_OU: "linked_to_other_ou",
UNCHANGED: "unchanged",
LINKED_TO_OTHER_NODE: "linked_to_other_node",
} as const;
export type OuRelation = (typeof OU_RELATION)[keyof typeof OU_RELATION];
export type NodeRelation = (typeof NODE_RELATION)[keyof typeof NODE_RELATION];
export const SECRET_STATE = {
ALREADY_EXISTS: "already_exists",
export const PROVIDER_SECRET_STATE = {
WILL_CREATE: "will_create",
MANUAL_REQUIRED: "manual_required",
WILL_REPLACE: "will_replace",
} as const;
export type SecretState = (typeof SECRET_STATE)[keyof typeof SECRET_STATE];
export type ProviderSecretState =
(typeof PROVIDER_SECRET_STATE)[keyof typeof PROVIDER_SECRET_STATE];
export const NODE_KIND = {
ORGANIZATIONAL_UNIT: "organizational-unit",
FOLDER: "folder",
} as const;
export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND];
export const ORG_SECRET_TYPE = {
ROLE: "role",
SERVICE_ACCOUNT: "service_account",
STATIC: "static",
} as const;
export type OrgSecretType =
(typeof ORG_SECRET_TYPE)[keyof typeof ORG_SECRET_TYPE];
export const ORG_WIZARD_STEP = {
SETUP: 0,
@@ -92,18 +107,48 @@ export const ORGANIZATION_TYPE = {
export type OrganizationType =
(typeof ORGANIZATION_TYPE)[keyof typeof ORGANIZATION_TYPE];
// ─── Discovery Result Interfaces ──────────────────────────────────────────────
/**
* Organization types with an org-level onboarding flow (wizard, credentials,
* discovery, apply). Display surfaces cover every `OrganizationType`; only these
* can be onboarded, so the two domains are narrowed with `isOrgFlowType`.
*/
export const ORG_FLOW_TYPES = [
ORGANIZATION_TYPE.AWS,
ORGANIZATION_TYPE.GCP,
] as const;
export interface AccountRegistration {
export type OrgFlowType = (typeof ORG_FLOW_TYPES)[number];
export function isOrgFlowType(
orgType: OrganizationType,
): orgType is OrgFlowType {
return (ORG_FLOW_TYPES as readonly OrganizationType[]).includes(orgType);
}
/**
* Narrows an untrusted value (form data, wire payload) to an onboarding-capable
* type `isOrgFlowType` narrows inside the type domain, this guards the
* boundary, the role `toNodeKind` plays for node kinds. `azure` is a real
* `OrganizationType` but has no onboarding flow, so it does not pass either.
*/
export function toOrgFlowType(orgType: unknown): OrgFlowType | undefined {
return ORG_FLOW_TYPES.find((flowType) => flowType === orgType);
}
// ─── Candidate Registration (shared wire shape) ───────────────────────────────
export interface CandidateRegistration {
provider_exists: boolean;
provider_id: string | null;
organization_relation: OrgRelation;
organizational_unit_relation: OuRelation;
provider_secret_state: SecretState;
organization_node_relation: NodeRelation;
provider_secret_state: ProviderSecretState;
apply_status: ApplyStatus;
blocked_reasons: string[];
}
// ─── AWS Discovery Result (wire) ───────────────────────────────────────────────
export interface DiscoveredAccount {
id: string;
name: string;
@@ -113,7 +158,7 @@ export interface DiscoveredAccount {
joined_method: DiscoveredAccountJoinedMethod;
joined_timestamp: string;
parent_id: string;
registration?: AccountRegistration;
registration?: CandidateRegistration;
}
export interface DiscoveredOu {
@@ -130,12 +175,157 @@ export interface DiscoveredRoot {
policy_types: OrganizationPolicyType[];
}
export interface DiscoveryResult {
export interface AwsDiscoveryResult {
roots: DiscoveredRoot[];
organizational_units: DiscoveredOu[];
accounts: DiscoveredAccount[];
}
// ─── GCP Discovery Result (wire) ───────────────────────────────────────────────
/**
* Identity here is the canonical resource `name` (`organizations/{id}`,
* `folders/{id}`) there is no `id` field, and a child's `parent` is exactly its
* parent's `name`. `display_name` is the only human label: a project's `name` is
* `projects/{number}`.
*/
export interface GcpDiscoveredOrganization {
name: string;
display_name: string;
}
export interface GcpDiscoveredFolder {
name: string;
display_name: string;
parent: string;
state?: string;
}
export interface GcpDiscoveredProject {
project_id: string;
name: string;
display_name: string;
parent: string;
state?: string;
labels?: Record<string, string>;
registration?: CandidateRegistration;
}
export interface GcpDiscoveryResult {
organization: GcpDiscoveredOrganization;
folders: GcpDiscoveredFolder[];
projects: GcpDiscoveredProject[];
}
/** Raw discovery `result` blob — per-provider, carries no discriminant on the wire. */
export type DiscoveryResult = AwsDiscoveryResult | GcpDiscoveryResult;
// ─── Normalized Hierarchy Model (store currency) ───────────────────────────────
export interface OrgHierarchyOrganization {
uid: string;
name: string;
}
export interface OrgNode {
id: string;
kind: NodeKind;
name: string;
parentId: string;
}
export interface OrgCandidate {
uid: string;
label: string;
parentId: string;
registration?: CandidateRegistration;
}
interface BaseOrgHierarchy {
organization: OrgHierarchyOrganization;
nodes: OrgNode[];
candidates: OrgCandidate[];
}
export interface AwsOrgHierarchy extends BaseOrgHierarchy {
orgType: typeof ORGANIZATION_TYPE.AWS;
}
export interface GcpOrgHierarchy extends BaseOrgHierarchy {
orgType: typeof ORGANIZATION_TYPE.GCP;
}
export type OrgHierarchy = AwsOrgHierarchy | GcpOrgHierarchy;
// ─── Secret + Apply Payloads (per-type) ────────────────────────────────────────
export interface AwsRoleSecret {
role_arn: string;
external_id: string;
}
export interface GcpServiceAccountSecret {
service_account_key: Record<string, unknown>;
}
export interface GcpStaticSecret {
client_id: string;
client_secret: string;
refresh_token: string;
}
export interface AwsRoleSecretPayload {
secretType: typeof ORG_SECRET_TYPE.ROLE;
secret: AwsRoleSecret;
}
export interface GcpServiceAccountSecretPayload {
secretType: typeof ORG_SECRET_TYPE.SERVICE_ACCOUNT;
secret: GcpServiceAccountSecret;
}
export interface GcpStaticSecretPayload {
secretType: typeof ORG_SECRET_TYPE.STATIC;
secret: GcpStaticSecret;
}
export type OrgSecretPayload =
| AwsRoleSecretPayload
| GcpServiceAccountSecretPayload
| GcpStaticSecretPayload;
/** A candidate the user chose to onboard, optionally renamed. */
export interface ApplyAccountSelection {
id: string;
alias?: string;
}
/** A hierarchy node the AWS apply derives client-side. */
export interface ApplyNodeSelection {
id: string;
}
/** GCP sends projects only; folder ancestors are derived server-side. */
export interface ApplyProjectSelection {
project_id: string;
alias?: string;
}
export interface AwsApplyDiscoveryPayload {
orgType: typeof ORGANIZATION_TYPE.AWS;
accounts: ApplyAccountSelection[];
organizationalUnits: ApplyNodeSelection[];
}
export interface GcpApplyDiscoveryPayload {
orgType: typeof ORGANIZATION_TYPE.GCP;
projects: ApplyProjectSelection[];
}
export type ApplyDiscoveryPayload =
| AwsApplyDiscoveryPayload
| GcpApplyDiscoveryPayload;
// ─── JSON:API Resource Interfaces ─────────────────────────────────────────────
export interface OrganizationAttributes {
@@ -148,13 +338,62 @@ export interface OrganizationAttributes {
updated_at?: string;
}
/** JSON:API resource identifier — the `{id, type}` every relationship points at. */
interface OrganizationResourceRef<T extends string = string> {
id: string;
type: T;
}
/** To-many relationship envelope. */
interface OrganizationRelationshipRef<T extends string = string> {
data: Array<{ id: string; type: T }>;
data: Array<OrganizationResourceRef<T>>;
}
/** To-many relationship the API annotates with a total. */
interface CountedRelationshipRef<T extends string = string>
extends OrganizationRelationshipRef<T> {
meta: RelationshipCount;
}
interface RelationshipCount {
count: number;
}
/** To-one relationship envelope. */
interface OrganizationToOneRef<T extends string = string> {
data: OrganizationResourceRef<T>;
}
/** To-one relationship that is explicitly null at the top of the hierarchy. */
interface OrganizationNullableToOneRef<T extends string = string> {
data: OrganizationResourceRef<T> | null;
}
interface OrganizationRelationships {
providers?: OrganizationRelationshipRef<"providers">;
organizational_units?: OrganizationRelationshipRef<"organizational-units">;
organization_nodes?: OrganizationRelationshipRef<"organization-nodes">;
}
interface CollectionPagination {
page?: number;
pages?: number;
count?: number;
}
/**
* Collection `meta`. One interface, not one per field: the list endpoints serve
* `version` and `pagination` in the same object, so splitting them would make
* each response type unable to describe half of its own payload.
*/
export interface CollectionMeta {
version?: string;
pagination?: CollectionPagination;
}
/** One page of a JSON:API collection, as the paginated read consumes it. */
export interface CollectionPage<T> {
data?: T[];
meta?: CollectionMeta;
}
export interface OrganizationResource {
@@ -164,44 +403,41 @@ export interface OrganizationResource {
relationships?: OrganizationRelationships;
}
export interface OrganizationListResponse {
data: OrganizationResource[];
meta?: {
version?: string;
};
}
export interface OrganizationUnitAttributes {
export interface OrganizationNodeAttributes {
name: string;
kind: NodeKind;
external_id: string;
parent_external_id: string | null;
/**
* Not served by `organization-nodes`, which parents through the `parent`
* relationship. Kept for the legacy attribute-parented grouping branch.
*/
parent_external_id?: string | null;
metadata: Record<string, unknown>;
inserted_at?: string;
updated_at?: string;
}
export interface OrganizationUnitRelationships {
organization: {
data: { id: string; type: "organizations" };
};
parent?: {
data: { id: string; type: "organizational-units" } | null;
};
export interface OrganizationNodeRelationships {
organization: OrganizationToOneRef<"organizations">;
parent?: OrganizationNullableToOneRef<"organization-nodes">;
providers?: OrganizationRelationshipRef<"providers">;
}
export interface OrganizationUnitResource {
export interface OrganizationNodeResource {
id: string;
type: "organizational-units";
attributes: OrganizationUnitAttributes;
relationships: OrganizationUnitRelationships;
type: "organization-nodes";
attributes: OrganizationNodeAttributes;
relationships: OrganizationNodeRelationships;
}
export interface OrganizationUnitListResponse {
data: OrganizationUnitResource[];
meta?: {
version?: string;
};
/**
* Result of a non-throwing ("safe") collection fetch. `data` is always present
* (empty on failure); `error` is set only when the request failed, letting
* callers tell a degraded fetch from a genuinely empty collection.
*/
export interface CollectionFetch<T> {
data: T[];
error?: boolean;
}
export interface DiscoveryAttributes {
@@ -222,18 +458,12 @@ export interface ApplyResultAttributes {
providers_created_count: number;
providers_linked_count: number;
providers_applied_count: number;
organizational_units_created_count: number;
organization_nodes_created_count: number;
}
export interface ApplyResultRelationships {
providers: {
data: Array<{ type: "providers"; id: string }>;
meta: { count: number };
};
organizational_units: {
data: Array<{ type: "organizational-units"; id: string }>;
meta: { count: number };
};
providers: CountedRelationshipRef<"providers">;
organization_nodes: CountedRelationshipRef<"organization-nodes">;
}
export interface ApplyResultResource {
+19 -2
View File
@@ -1,8 +1,10 @@
import { MetaDataProps, ProviderGroup } from "./components";
import { FilterOption } from "./filters";
import {
NodeKind,
OrganizationNodeResource,
OrganizationResource,
OrganizationUnitResource,
OrganizationType,
} from "./organizations";
import { ProviderProps } from "./providers";
import { ScanScheduleSummary } from "./scans";
@@ -23,6 +25,14 @@ export const PROVIDERS_GROUP_KIND = {
export type ProvidersGroupKind =
(typeof PROVIDERS_GROUP_KIND)[keyof typeof PROVIDERS_GROUP_KIND];
export const HIERARCHY_STATUS = {
AVAILABLE: "available",
UNAVAILABLE: "unavailable",
} as const;
export type HierarchyStatus =
(typeof HIERARCHY_STATUS)[keyof typeof HIERARCHY_STATUS];
export const PROVIDERS_PAGE_FILTER = {
PROVIDER: "provider__in",
PROVIDER_TYPE: "provider_type__in",
@@ -43,6 +53,9 @@ export interface ProviderTableRelationshipRef {
export type ProviderTableRelationships = ProviderProps["relationships"] & {
organization?: ProviderTableRelationshipRef;
// Canonical provider→node relationship, plus deprecated aliases tolerated
// during the transition window.
organization_node?: ProviderTableRelationshipRef;
organization_unit?: ProviderTableRelationshipRef;
organizational_unit?: ProviderTableRelationshipRef;
};
@@ -64,6 +77,8 @@ export interface ProvidersOrganizationRow {
id: string;
rowType: typeof PROVIDERS_ROW_TYPE.ORGANIZATION;
groupKind: ProvidersGroupKind;
orgType: OrganizationType;
kind?: NodeKind;
name: string;
externalId: string | null;
parentExternalId: string | null;
@@ -78,7 +93,7 @@ export type ProvidersTableRow = ProvidersOrganizationRow | ProvidersProviderRow;
export interface ProvidersTableRowsInput {
isCloud: boolean;
organizations: OrganizationResource[];
organizationUnits: OrganizationUnitResource[];
organizationNodes: OrganizationNodeResource[];
providers: ProvidersProviderRow[];
}
@@ -88,6 +103,8 @@ export interface ProvidersAccountsViewData {
providers: ProviderProps[];
providerGroups: ProviderGroup[];
rows: ProvidersTableRow[];
/** `unavailable` when the hierarchy fetch failed (drives the degraded notice). */
hierarchyStatus: HierarchyStatus;
}
export function isProvidersOrganizationRow(
+12 -4
View File
@@ -97,20 +97,28 @@ export interface SchedulesBulkFailure {
error: string;
}
export interface SchedulesBulkAttributes {
export interface SchedulesBulkLists {
/** Provider ids whose schedule was committed (already excludes failures). */
updated?: string[];
failed?: SchedulesBulkFailure[];
}
export interface SchedulesBulkData {
type: "schedules-bulk";
/**
* `/schedules/bulk` answers with a plain dict that the JSON:API renderer wraps in
* `data`, so the lists sit directly on it: no `attributes` level and no `type`,
* despite what the endpoint's documented response schema says.
*/
export interface SchedulesBulkData extends SchedulesBulkLists {
type?: "schedules-bulk";
id?: string;
attributes?: SchedulesBulkAttributes;
/** Tolerated only in case the endpoint is ever rendered through its serializer. */
attributes?: SchedulesBulkLists;
}
export interface SchedulesBulkResponse {
data?: SchedulesBulkData;
/** `handleApiResponse` returns `{ success: true }` for an empty or 204 body. */
success?: boolean;
error?: unknown;
errors?: unknown;
status?: number;
+1
View File
@@ -39,6 +39,7 @@ export interface TreeDataItem {
errorMessage?: string;
/** Additional CSS classes for the item */
className?: string;
kind?: string;
}
/**
+27 -7
View File
@@ -8,7 +8,6 @@ export default defineConfig(() => {
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
@@ -28,16 +27,21 @@ export default defineConfig(() => {
".next",
"tests/**/*",
"**/*.test.{ts,tsx}",
"**/*.browser.test.{ts,tsx}",
"**/*.integration.test.{ts,tsx}",
"vitest.config.ts",
"vitest.setup.ts",
"vitest.browser.setup.ts",
"vitest.integration.setup.ts",
"__tests__/**/*",
],
},
projects: [
{
extends: true,
// Unit (jsdom) suite runs without the React Compiler: enabling it
// breaks async Server Components (`useMemoCache` on a null
// dispatcher) and some form-validation renders. Only the browser
// suite below needs it.
plugins: [react()],
test: {
name: "unit",
environment: "jsdom",
@@ -47,16 +51,23 @@ export default defineConfig(() => {
"node_modules",
".next",
"tests/**/*",
"**/*.browser.test.{ts,tsx}",
"**/*.integration.test.{ts,tsx}",
],
},
},
{
extends: true,
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
},
}),
],
test: {
name: "browser",
setupFiles: ["./vitest.browser.setup.ts"],
include: ["**/*.browser.test.{ts,tsx}"],
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
include: ["**/*.integration.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "tests/**/*"],
browser: {
enabled: true,
@@ -93,6 +104,10 @@ export default defineConfig(() => {
"vitest-browser-react",
"msw/browser",
// React runtime (pre-bundle so a cold run doesn't re-optimize and
// reload mid-test — see the on-demand-reload note above).
"react-dom/client",
// Next runtime
"next/navigation",
"next/link",
@@ -117,6 +132,7 @@ export default defineConfig(() => {
"@radix-ui/react-icons",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-radio-group",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
@@ -126,6 +142,7 @@ export default defineConfig(() => {
"@radix-ui/react-toast",
"@radix-ui/react-tooltip",
"@radix-ui/react-slot",
"@radix-ui/react-use-controllable-state",
// Graph
"@xyflow/react",
@@ -137,6 +154,7 @@ export default defineConfig(() => {
"zod",
"zustand",
"zustand/middleware",
"zustand/vanilla",
// Styling helpers
"lucide-react",
@@ -151,7 +169,9 @@ export default defineConfig(() => {
"modern-screenshot",
"framer-motion",
"cmdk",
"driver.js",
"react-markdown",
"streamdown",
"jwt-decode",
"date-fns",
"js-yaml",
@@ -8,6 +8,8 @@ import "@/styles/globals.css";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { resetToasts } from "@/components/shadcn/toast/use-toast";
import { worker } from "./__tests__/msw/worker";
// Server Actions ("use server") are bundled by Vite as plain async functions
@@ -25,6 +27,18 @@ vi.mock("@/auth.config", () => ({
handlers: {},
}));
// Server Actions call `revalidatePath`/`revalidateTag` from `next/cache` after
// mutations. Those read Next's static-generation store, which only exists
// inside a Next request scope — in the browser test they throw "Invariant:
// static generation store missing", aborting the action before the UI can
// react. Stub them as no-ops; the tests assert on UI state, not cache
// invalidation.
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: <T>(fn: T) => fn,
}));
// Next.js's App Router context (`useRouter`, `useSearchParams`, `usePathname`)
// is not available in vitest browser — there's no Next runtime mounting the
// providers. We back the hooks with the real `window.location` so navigating
@@ -57,6 +71,9 @@ beforeAll(async () => {
afterEach(() => {
worker.resetHandlers();
// Module-level store: a toast raised here would re-render on the next test's
// mount and intercept clicks aimed at the page.
resetToasts();
});
afterAll(() => {