mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-20 18:34:20 +00:00
refactor(ui): extract account selection utils and tests
This commit is contained in:
2 files changed
+448
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CONNECTION_TEST_STATUS } from "@/types/organizations";
|
||||
|
||||
import {
|
||||
buildAccountToProviderMap,
|
||||
canAdvanceToLaunchStep,
|
||||
getLaunchableProviderIds,
|
||||
pollConnectionTask,
|
||||
runWithConcurrencyLimit,
|
||||
} 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"];
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const map = await buildAccountToProviderMap({
|
||||
selectedAccountIds,
|
||||
providerIds,
|
||||
applyResult,
|
||||
resolveProviderUidById,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(map.get("111111111111")).toBe("provider-a");
|
||||
expect(map.get("222222222222")).toBe("provider-b");
|
||||
expect(resolveProviderUidById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to provider uid matching when explicit mappings are missing", async () => {
|
||||
// Given
|
||||
const selectedAccountIds = ["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";
|
||||
});
|
||||
|
||||
// When
|
||||
const map = await buildAccountToProviderMap({
|
||||
selectedAccountIds,
|
||||
providerIds,
|
||||
applyResult: {},
|
||||
resolveProviderUidById,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(map.get("111111111111")).toBe("provider-c");
|
||||
expect(map.get("222222222222")).toBe("provider-a");
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(maxActiveWorkers).toBeLessThanOrEqual(3);
|
||||
expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pollConnectionTask", () => {
|
||||
it("uses progressive delays and returns connection result from the final task payload", async () => {
|
||||
// Given
|
||||
const sleeps: number[] = [];
|
||||
const getTaskById = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { state: "executing" } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { state: "executing" } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
attributes: {
|
||||
state: "completed",
|
||||
result: { connected: false, error: "Role trust policy mismatch." },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollConnectionTask("task-1", {
|
||||
getTaskById,
|
||||
sleep: async (delay) => {
|
||||
sleeps.push(delay);
|
||||
},
|
||||
maxRetries: 5,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(sleeps).toEqual([2000, 3000]);
|
||||
expect(getTaskById).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Role trust policy mismatch.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("launch gating", () => {
|
||||
it("blocks advancing when all tested providers failed", () => {
|
||||
// Given
|
||||
const providerIds = ["provider-a", "provider-b"];
|
||||
const connectionResults = {
|
||||
"provider-a": CONNECTION_TEST_STATUS.ERROR,
|
||||
"provider-b": CONNECTION_TEST_STATUS.ERROR,
|
||||
};
|
||||
|
||||
// When
|
||||
const launchableProviderIds = getLaunchableProviderIds(
|
||||
providerIds,
|
||||
connectionResults,
|
||||
);
|
||||
const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults);
|
||||
|
||||
// Then
|
||||
expect(launchableProviderIds).toEqual([]);
|
||||
expect(canAdvance).toBe(false);
|
||||
});
|
||||
|
||||
it("allows advancing and keeps only successful providers", () => {
|
||||
// Given
|
||||
const providerIds = ["provider-a", "provider-b"];
|
||||
const connectionResults = {
|
||||
"provider-a": CONNECTION_TEST_STATUS.SUCCESS,
|
||||
"provider-b": CONNECTION_TEST_STATUS.ERROR,
|
||||
};
|
||||
|
||||
// When
|
||||
const launchableProviderIds = getLaunchableProviderIds(
|
||||
providerIds,
|
||||
connectionResults,
|
||||
);
|
||||
const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults);
|
||||
|
||||
// Then
|
||||
expect(launchableProviderIds).toEqual(["provider-a"]);
|
||||
expect(canAdvance).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
CONNECTION_TEST_STATUS,
|
||||
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[];
|
||||
providerIds: string[];
|
||||
applyResult: unknown;
|
||||
resolveProviderUidById: (providerId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
interface PollConnectionTaskOptions {
|
||||
getTaskById?: (taskId: string) => Promise<unknown>;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
maxRetries?: number;
|
||||
delaysMs?: number[];
|
||||
}
|
||||
|
||||
export interface PollConnectionTaskResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getPollingDelay(attempt: number, delaysMs: number[]): number {
|
||||
if (delaysMs.length === 0) {
|
||||
return DEFAULT_POLL_DELAYS_MS[DEFAULT_POLL_DELAYS_MS.length - 1];
|
||||
}
|
||||
const delayIndex = Math.min(attempt, delaysMs.length - 1);
|
||||
return delaysMs[delayIndex] ?? delaysMs[delaysMs.length - 1];
|
||||
}
|
||||
|
||||
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,
|
||||
providerIds,
|
||||
applyResult,
|
||||
resolveProviderUidById,
|
||||
}: BuildAccountToProviderMapParams): Promise<Map<string, string>> {
|
||||
const selectedAccountIdSet = new Set(selectedAccountIds);
|
||||
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
fallbackMapping.set(entry.accountId, entry.providerId);
|
||||
}
|
||||
|
||||
return fallbackMapping;
|
||||
}
|
||||
|
||||
export async function pollConnectionTask(
|
||||
taskId: string,
|
||||
{
|
||||
getTaskById,
|
||||
sleep = async (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
maxRetries = 20,
|
||||
delaysMs = [...DEFAULT_POLL_DELAYS_MS],
|
||||
}: PollConnectionTaskOptions = {},
|
||||
): Promise<PollConnectionTaskResult> {
|
||||
const inProgressStates = new Set([
|
||||
"available",
|
||||
"scheduled",
|
||||
"executing",
|
||||
"pending",
|
||||
"running",
|
||||
]);
|
||||
const taskFetcher =
|
||||
getTaskById ??
|
||||
(async (currentTaskId: string) => {
|
||||
const { getTask } = await import("@/actions/task/tasks");
|
||||
return getTask(currentTaskId);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
||||
const taskResponse = await taskFetcher(taskId);
|
||||
|
||||
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 || !inProgressStates.has(state)) {
|
||||
return { success: false, error: "Unexpected task state." };
|
||||
}
|
||||
|
||||
await sleep(getPollingDelay(attempt, delaysMs));
|
||||
}
|
||||
|
||||
return { success: false, error: "Connection test timed out." };
|
||||
}
|
||||
|
||||
export function getLaunchableProviderIds(
|
||||
providerIds: string[],
|
||||
connectionResults: Record<string, ConnectionTestStatus>,
|
||||
): string[] {
|
||||
return providerIds.filter(
|
||||
(providerId) =>
|
||||
connectionResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS,
|
||||
);
|
||||
}
|
||||
|
||||
export function canAdvanceToLaunchStep(
|
||||
providerIds: string[],
|
||||
connectionResults: Record<string, ConnectionTestStatus>,
|
||||
): boolean {
|
||||
return getLaunchableProviderIds(providerIds, connectionResults).length > 0;
|
||||
}
|
||||
Reference in new issue
Block a user