mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
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:
co-authored by
Pablo F.G
parent
b56df840fc
commit
2db3bebd15
@@ -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'
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# testing
|
||||
/coverage
|
||||
__screenshots__/
|
||||
.vitest-attachments/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+45
-57
@@ -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);
|
||||
|
||||
+22
-27
@@ -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,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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
-286
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface TreeDataItem {
|
||||
errorMessage?: string;
|
||||
/** Additional CSS classes for the item */
|
||||
className?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+27
-7
@@ -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(() => {
|
||||
Reference in New Issue
Block a user