diff --git a/.gitignore b/.gitignore index d0372e9d3..7ee99f08f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ coverage-integration/ logs/ *.log +# Local smoke and scratch files +.tmp/ + # Optional npm cache directory .npm diff --git a/README.md b/README.md index b1ff35b74..246fdb77c 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ The SDK provides access to the following services through modular imports: - `CaseInstances` from `@uipath/uipath-typescript/cases` - Manage maestro case executions - `Tasks` from `@uipath/uipath-typescript/tasks` - Create and manage tasks - `Entities` from `@uipath/uipath-typescript/entities` - Data Fabric entity operations +- `ChoiceSets` from `@uipath/uipath-typescript/entities` - Data Fabric choice set operations - `Processes` from `@uipath/uipath-typescript/processes` - Manage Orchestrator processes - `Buckets` from `@uipath/uipath-typescript/buckets` - Manage storage buckets in Orchestrator - `Queues` from `@uipath/uipath-typescript/queues` - Manage Orchestrator queues @@ -281,7 +282,7 @@ import { Cases, CaseInstances } from '@uipath/uipath-typescript/cases'; import { Tasks, TaskType } from '@uipath/uipath-typescript/tasks'; import { Processes } from '@uipath/uipath-typescript/processes'; import { Buckets } from '@uipath/uipath-typescript/buckets'; -import { Entities } from '@uipath/uipath-typescript/entities'; +import { ChoiceSets, Entities } from '@uipath/uipath-typescript/entities'; // Initialize SDK const sdk = new UiPath({ /* config */ }); @@ -295,6 +296,7 @@ const tasks = new Tasks(sdk); const processes = new Processes(sdk); const buckets = new Buckets(sdk); const entities = new Entities(sdk); +const choiceSets = new ChoiceSets(sdk); // Maestro - Get processes and their instances const allProcesses = await maestroProcesses.getAll(); @@ -367,6 +369,7 @@ const records = await entities.getAllRecords('entity-uuid', { pageSize: 100, expansionLevel: 1 }); +const allChoiceSets = await choiceSets.getAll(); // Insert records await entities.insertRecordsById('entity-uuid', [ diff --git a/src/models/data-fabric/directory.internal-types.ts b/src/models/data-fabric/directory.internal-types.ts new file mode 100644 index 000000000..75012e571 --- /dev/null +++ b/src/models/data-fabric/directory.internal-types.ts @@ -0,0 +1,38 @@ +import { + DataFabricDirectoryEntityType, + DataFabricDirectoryEntityTypeName, +} from './directory.types'; + +export interface DataFabricDirectoryAssignPayload { + directoryEntities: Array<{ + externalId: string; + type: DataFabricDirectoryEntityType; + resolved: true; + }>; + roles: string[]; + isUIEnabled: boolean; +} + +export interface DataFabricDirectoryRevokePayload { + externalIds: string[]; +} + +export interface RawDataFabricDirectoryRole { + id: string; + name: string; +} + +export interface RawDataFabricDirectoryEntry { + externalId: string; + name: string; + email?: string | null; + type: DataFabricDirectoryEntityTypeName; + roles?: RawDataFabricDirectoryRole[] | null; + objectType?: string | null; + isUIEnabled: boolean; +} + +export interface RawDataFabricDirectoryListResponse { + totalCount: number; + results: RawDataFabricDirectoryEntry[]; +} diff --git a/src/models/data-fabric/directory.models.ts b/src/models/data-fabric/directory.models.ts new file mode 100644 index 000000000..083168b99 --- /dev/null +++ b/src/models/data-fabric/directory.models.ts @@ -0,0 +1,137 @@ +import { + DataFabricDirectoryAssignOptions, + DataFabricDirectoryAssignmentResult, + DataFabricDirectoryEntityTypeInput, + DataFabricDirectoryEntry, + DataFabricDirectoryGetAllOptions, + DataFabricDirectoryListOptions, + DataFabricDirectoryListResponse, +} from './directory.types'; + +/** + * @internal + */ +export interface DataFabricDirectoryServiceModel { + /** + * Lists one page of Data Fabric directory principals and their current roles. + * + * Returns directory entries with external IDs, principal metadata, and + * assigned Data Fabric roles. + * + * @param options - Optional offset paging options + * @returns Promise resolving to {@link DataFabricDirectoryListResponse} + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * const page = await directory.list({ skip: 0, top: 50 }); + * const firstPrincipal = page.results[0]; + * ``` + * + * @internal + */ + list(options?: DataFabricDirectoryListOptions): Promise; + + /** + * Lists all Data Fabric directory principals and their current roles. + * + * Follows the Data Fabric directory top/skip pagination and returns + * normalized entries. Entries without assigned roles include an empty + * `roles` array. + * + * @param options - Optional page-size options + * @returns Promise resolving to an array of {@link DataFabricDirectoryEntry} + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * const principals = await directory.getAll({ pageSize: 100 }); + * ``` + * + * @internal + */ + getAll(options?: DataFabricDirectoryGetAllOptions): Promise; + + /** + * Assigns Data Fabric roles to one or more principals. + * + * The Data Fabric API replaces the role set for each principal, so this + * method preserves existing roles by default and posts the union of current + * and requested role IDs. + * + * Role IDs can be discovered with `DataFabricRoleService.getAll()`. Set + * `preserveExisting: false` only when intentionally replacing a principal's + * Data Fabric role set. + * + * @param principalIds - Principal external ID or IDs + * @param principalType - Principal type + * @param roleIds - Data Fabric role IDs to assign + * @param options - Optional assignment behavior + * @returns Promise resolving to an array of {@link DataFabricDirectoryAssignmentResult} + * + * @example + * ```typescript + * import { DataFabricDirectoryEntityTypeName, DataFabricDirectoryService, DataFabricRoleService } from '@uipath/uipath-typescript/entities'; + * + * const roles = new DataFabricRoleService(sdk); + * const directory = new DataFabricDirectoryService(sdk); + * + * const dataWriter = (await roles.getAll()).find(role => role.name === 'DataWriter'); + * if (!dataWriter) { + * throw new Error('DataWriter role not found'); + * } + * + * await directory.assignRoles('', DataFabricDirectoryEntityTypeName.Group, [dataWriter.id]); + * ``` + * + * @example + * ```typescript + * await directory.assignRoles('', DataFabricDirectoryEntityTypeName.User, [''], { + * preserveExisting: false, + * }); + * ``` + * + * @internal + */ + assignRoles( + principalIds: string | string[], + principalType: DataFabricDirectoryEntityTypeInput, + roleIds: string[], + options?: DataFabricDirectoryAssignOptions + ): Promise; + + /** + * Revokes all direct Data Fabric roles from one or more principals. + * + * The Data Fabric API removes all role assignments for each supplied external + * ID. Use this when a principal should no longer have direct Data Fabric + * access. Inherited access through groups is not changed. + * + * @param principalIds - Principal external ID or IDs + * @returns Promise resolving when the roles are revoked + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * + * await directory.revokeRoles(''); + * ``` + * + * @example + * ```typescript + * await directory.revokeRoles([ + * '', + * '', + * ]); + * ``` + * + * @internal + */ + revokeRoles(principalIds: string | string[]): Promise; +} diff --git a/src/models/data-fabric/directory.types.ts b/src/models/data-fabric/directory.types.ts new file mode 100644 index 000000000..95eda4017 --- /dev/null +++ b/src/models/data-fabric/directory.types.ts @@ -0,0 +1,98 @@ +/** + * @internal + */ +export enum DataFabricDirectoryEntityType { + /** Identity user, robot user, or directory robot principal. */ + User = 0, + /** Identity group principal. */ + Group = 1, + /** External application principal. */ + Application = 2, +} + +/** + * @internal + */ +export enum DataFabricDirectoryEntityTypeName { + User = 'User', + Group = 'Group', + Application = 'Application', +} + +/** + * @internal + */ +export type DataFabricDirectoryEntityTypeInput = + | DataFabricDirectoryEntityType + | DataFabricDirectoryEntityTypeName; + +/** + * @internal + */ +export interface DataFabricDirectoryRole { + id: string; + name: string; +} + +/** + * @internal + */ +export interface DataFabricDirectoryEntry { + externalId: string; + name: string; + email?: string | null; + type: DataFabricDirectoryEntityTypeName; + roles: DataFabricDirectoryRole[]; + objectType?: string | null; + isUIEnabled: boolean; +} + +/** + * @internal + */ +export interface DataFabricDirectoryListOptions { + skip?: number; + top?: number; +} + +/** + * @internal + */ +export interface DataFabricDirectoryGetAllOptions { + pageSize?: number; +} + +/** + * @internal + */ +export interface DataFabricDirectoryListResponse { + totalCount: number; + results: DataFabricDirectoryEntry[]; +} + +/** + * @internal + */ +export interface DataFabricDirectoryAssignOptions { + /** + * Preserve the principal's current Data Fabric roles. + * + * Defaults to true because the Data Fabric role assignment endpoint replaces + * the role set for each principal. + */ + preserveExisting?: boolean; + /** + * Enables Data Fabric UI access for the assigned principal. + * + * Defaults to true. + */ + uiEnabled?: boolean; +} + +/** + * @internal + */ +export interface DataFabricDirectoryAssignmentResult { + principalId: string; + roleIds: string[]; +} diff --git a/src/models/data-fabric/index.ts b/src/models/data-fabric/index.ts index f35153130..d71bc75e2 100644 --- a/src/models/data-fabric/index.ts +++ b/src/models/data-fabric/index.ts @@ -1,4 +1,4 @@ export * from './entities.types'; export * from './entities.models'; export * from './choicesets.types'; -export * from './choicesets.models'; \ No newline at end of file +export * from './choicesets.models'; diff --git a/src/models/data-fabric/roles.models.ts b/src/models/data-fabric/roles.models.ts new file mode 100644 index 000000000..f274f9fa4 --- /dev/null +++ b/src/models/data-fabric/roles.models.ts @@ -0,0 +1,39 @@ +import { DataFabricRole, DataFabricRoleGetAllOptions } from './roles.types'; + +/** + * @internal + */ +export interface DataFabricRoleServiceModel { + /** + * Lists Data Fabric access roles. + * + * Returns tenant Data Fabric roles such as Admin, Designer, DataWriter, and + * DataReader. Role IDs from this method can be passed to + * `DataFabricDirectoryService.assignRoles()`. + * + * @param options - Optional query options + * @returns Promise resolving to an array of {@link DataFabricRole} + * + * @example + * ```typescript + * import { DataFabricRoleService } from '@uipath/uipath-typescript/entities'; + * + * const roles = new DataFabricRoleService(sdk); + * const allRoles = await roles.getAll(); + * const dataWriter = allRoles.find(role => role.name === 'DataWriter'); + * ``` + * + * @example + * ```typescript + * const rolesWithoutStats = await roles.getAll({ stats: false }); + * ``` + * + * @example + * ```typescript + * const folderRoles = await roles.getAll({ folderKey: '' }); + * ``` + * + * @internal + */ + getAll(options?: DataFabricRoleGetAllOptions): Promise; +} diff --git a/src/models/data-fabric/roles.types.ts b/src/models/data-fabric/roles.types.ts new file mode 100644 index 000000000..34b0bb9fd --- /dev/null +++ b/src/models/data-fabric/roles.types.ts @@ -0,0 +1,37 @@ +/** + * @internal + */ +export enum DataFabricRoleType { + System = 'System', + UserDefined = 'UserDefined', +} + +/** + * @internal + */ +export interface DataFabricRole { + id: string; + name: string; + type: DataFabricRoleType; + directoryEntityCount?: number | null; + folderId?: string; +} + +/** + * @internal + */ +export interface DataFabricRoleGetAllOptions { + /** + * Include role statistics in the response. + * + * Defaults to true to match the Data Fabric UI and CLI role-list flow. + */ + stats?: boolean; + + /** + * Optional folder key for folder-aware Role V2 requests. + * + * Forwarded on the wire as the `X-UIPATH-FolderKey` header. + */ + folderKey?: string; +} diff --git a/src/services/data-fabric/directory.ts b/src/services/data-fabric/directory.ts new file mode 100644 index 000000000..2d66fe623 --- /dev/null +++ b/src/services/data-fabric/directory.ts @@ -0,0 +1,358 @@ +import { ServerError, ValidationError } from '../../core/errors'; +import { track } from '../../core/telemetry'; +import { DataFabricDirectoryServiceModel } from '../../models/data-fabric/directory.models'; +import { + DataFabricDirectoryAssignOptions, + DataFabricDirectoryAssignmentResult, + DataFabricDirectoryEntityType, + DataFabricDirectoryEntityTypeInput, + DataFabricDirectoryEntityTypeName, + DataFabricDirectoryEntry, + DataFabricDirectoryGetAllOptions, + DataFabricDirectoryListOptions, + DataFabricDirectoryListResponse, +} from '../../models/data-fabric/directory.types'; +import { + DataFabricDirectoryAssignPayload, + DataFabricDirectoryRevokePayload, + RawDataFabricDirectoryListResponse, +} from '../../models/data-fabric/directory.internal-types'; +import { DATA_FABRIC_ENDPOINTS } from '../../utils/constants/endpoints/data-fabric'; +import { createParams } from '../../utils/http/params'; +import { BaseService } from '../base'; + +const DEFAULT_DIRECTORY_PAGE_SIZE = 100; +const MAX_DIRECTORY_PAGE_SIZE = 100; + +function validateDirectoryListResponse(data: unknown): RawDataFabricDirectoryListResponse { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new ServerError({ + message: 'Invalid Data Fabric directory response format.', + }); + } + + const response = data as Partial; + if (typeof response.totalCount !== 'number' || !Array.isArray(response.results)) { + throw new ServerError({ + message: 'Invalid Data Fabric directory response format.', + }); + } + + return { + totalCount: response.totalCount, + results: response.results, + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isDirectoryEntityTypeName(value: unknown): value is DataFabricDirectoryEntry['type'] { + return value === DataFabricDirectoryEntityTypeName.User || + value === DataFabricDirectoryEntityTypeName.Group || + value === DataFabricDirectoryEntityTypeName.Application; +} + +function isDirectoryRole(value: unknown): value is DataFabricDirectoryEntry['roles'][number] { + if (!isRecord(value)) { + return false; + } + return typeof value.id === 'string' && typeof value.name === 'string'; +} + +function normalizeDirectoryEntry(entry: unknown): DataFabricDirectoryEntry { + if (!isRecord(entry) || + typeof entry.externalId !== 'string' || + typeof entry.name !== 'string' || + !isDirectoryEntityTypeName(entry.type) || + (entry.email !== undefined && entry.email !== null && typeof entry.email !== 'string') || + (entry.objectType !== undefined && entry.objectType !== null && typeof entry.objectType !== 'string') || + (entry.isUIEnabled !== undefined && typeof entry.isUIEnabled !== 'boolean') || + (entry.roles !== undefined && entry.roles !== null && (!Array.isArray(entry.roles) || !entry.roles.every(isDirectoryRole))) + ) { + throw new ServerError({ + message: 'Invalid Data Fabric directory entry response format.', + }); + } + + const normalized: DataFabricDirectoryEntry = { + externalId: entry.externalId as string, + name: entry.name as string, + type: entry.type as DataFabricDirectoryEntry['type'], + roles: (entry.roles as DataFabricDirectoryEntry['roles'] | null | undefined) ?? [], + isUIEnabled: (entry.isUIEnabled as boolean | undefined) ?? true, + }; + if (entry.email !== undefined) { + normalized.email = entry.email as string | null; + } + if (entry.objectType !== undefined) { + normalized.objectType = entry.objectType as string | null; + } + return normalized; +} + +function normalizePrincipalIds(principalIds: string | string[]): string[] { + const ids = Array.isArray(principalIds) ? principalIds : [principalIds]; + return [...new Set(ids.map(id => id.trim()).filter(Boolean))]; +} + +function normalizeRoleIds(roleIds: string[]): string[] { + return [...new Set(roleIds.map(id => id.trim()).filter(Boolean))]; +} + +function normalizePrincipalType(type: DataFabricDirectoryEntityTypeInput): DataFabricDirectoryEntityType { + if (typeof type === 'number') { + if ( + type === DataFabricDirectoryEntityType.User || + type === DataFabricDirectoryEntityType.Group || + type === DataFabricDirectoryEntityType.Application + ) { + return type; + } + throw new ValidationError({ + message: 'Invalid Data Fabric principal type.', + }); + } + + switch (type) { + case DataFabricDirectoryEntityTypeName.User: + return DataFabricDirectoryEntityType.User; + case DataFabricDirectoryEntityTypeName.Group: + return DataFabricDirectoryEntityType.Group; + case DataFabricDirectoryEntityTypeName.Application: + return DataFabricDirectoryEntityType.Application; + default: + throw new ValidationError({ + message: 'Invalid Data Fabric principal type.', + }); + } +} + +function roleIdsFromEntry(entry: DataFabricDirectoryEntry | undefined): string[] { + if (!entry) { + return []; + } + return normalizeRoleIds(entry.roles.map(role => role.id)); +} + +function clampDirectoryPageSize(pageSize?: number): number { + return Math.max(1, Math.min(pageSize ?? DEFAULT_DIRECTORY_PAGE_SIZE, MAX_DIRECTORY_PAGE_SIZE)); +} + +/** + * @internal + */ +export class DataFabricDirectoryService extends BaseService implements DataFabricDirectoryServiceModel { + private async fetchAllEntries(options: DataFabricDirectoryGetAllOptions = {}): Promise { + const top = clampDirectoryPageSize(options.pageSize); + const entries: DataFabricDirectoryEntry[] = []; + let skip = 0; + + while (true) { + const page = await this.list(skip === 0 ? { top } : { top, skip }); + entries.push(...page.results); + + if (page.results.length < top || (page.totalCount !== undefined && entries.length >= page.totalCount)) { + return entries; + } + + skip += top; + } + } + + /** + * Lists one page of Data Fabric directory principals and their current roles. + * + * Returns directory entries with external IDs, principal metadata, and + * assigned Data Fabric roles. + * + * @param options - Optional offset paging options + * @returns Promise resolving to {@link DataFabricDirectoryListResponse} + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * const page = await directory.list({ skip: 0, top: 50 }); + * const firstPrincipal = page.results[0]; + * ``` + * + * @internal + */ + @track('DataFabricDirectory.List') + async list(options: DataFabricDirectoryListOptions = {}): Promise { + const params = createParams({ + skip: options.skip, + top: clampDirectoryPageSize(options.top), + }); + const response = await this.get( + DATA_FABRIC_ENDPOINTS.DIRECTORY.GET_ALL, + { params } + ); + const data = validateDirectoryListResponse(response.data); + const results = data.results.map(normalizeDirectoryEntry); + return { + totalCount: data.totalCount, + results, + }; + } + + /** + * Lists all Data Fabric directory principals and their current roles. + * + * Follows the Data Fabric directory top/skip pagination and returns + * normalized entries. Entries without assigned roles include an empty + * `roles` array. + * + * @param options - Optional page-size options + * @returns Promise resolving to an array of {@link DataFabricDirectoryEntry} + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * const principals = await directory.getAll({ pageSize: 100 }); + * ``` + * + * @internal + */ + @track('DataFabricDirectory.GetAll') + async getAll(options: DataFabricDirectoryGetAllOptions = {}): Promise { + return this.fetchAllEntries(options); + } + + /** + * Assigns Data Fabric roles to one or more principals. + * + * The Data Fabric API replaces the role set for each principal, so this + * method preserves existing roles by default and posts the union of current + * and requested role IDs. + * + * Role IDs can be discovered with `DataFabricRoleService.getAll()`. Set + * `preserveExisting: false` only when intentionally replacing a principal's + * Data Fabric role set. + * + * @param principalIds - Principal external ID or IDs + * @param principalType - Principal type + * @param roleIds - Data Fabric role IDs to assign + * @param options - Optional assignment behavior + * @returns Promise resolving to an array of {@link DataFabricDirectoryAssignmentResult} + * + * @example + * ```typescript + * import { DataFabricDirectoryEntityTypeName, DataFabricDirectoryService, DataFabricRoleService } from '@uipath/uipath-typescript/entities'; + * + * const roles = new DataFabricRoleService(sdk); + * const directory = new DataFabricDirectoryService(sdk); + * + * const dataWriter = (await roles.getAll()).find(role => role.name === 'DataWriter'); + * if (!dataWriter) { + * throw new Error('DataWriter role not found'); + * } + * + * await directory.assignRoles('', DataFabricDirectoryEntityTypeName.Group, [dataWriter.id]); + * ``` + * + * @example + * ```typescript + * await directory.assignRoles('', DataFabricDirectoryEntityTypeName.User, [''], { + * preserveExisting: false, + * }); + * ``` + * + * @internal + */ + @track('DataFabricDirectory.AssignRoles') + async assignRoles( + principalIds: string | string[], + principalType: DataFabricDirectoryEntityTypeInput, + roleIds: string[], + options: DataFabricDirectoryAssignOptions = {} + ): Promise { + const normalizedPrincipalIds = normalizePrincipalIds(principalIds); + const normalizedRoleIds = normalizeRoleIds(roleIds); + if (normalizedPrincipalIds.length === 0) { + throw new ValidationError({ message: 'At least one principal ID is required.' }); + } + if (normalizedRoleIds.length === 0) { + throw new ValidationError({ message: 'At least one Data Fabric role ID is required.' }); + } + + const type = normalizePrincipalType(principalType); + const preserveExisting = options.preserveExisting ?? true; + const existingById = new Map(); + if (preserveExisting) { + for (const entry of await this.fetchAllEntries()) { + existingById.set(entry.externalId.toLowerCase(), entry); + } + } + + return Promise.all(normalizedPrincipalIds.map(async (principalId) => { + const existing = existingById.get(principalId.toLowerCase()); + const mergedRoleIds = preserveExisting + ? normalizeRoleIds([...roleIdsFromEntry(existing), ...normalizedRoleIds]) + : normalizedRoleIds; + const payload: DataFabricDirectoryAssignPayload = { + directoryEntities: [ + { + externalId: principalId, + type, + resolved: true, + }, + ], + roles: mergedRoleIds, + isUIEnabled: options.uiEnabled ?? true, + }; + await this.post(DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, payload); + return { + principalId, + roleIds: mergedRoleIds, + }; + })); + } + + /** + * Revokes all direct Data Fabric roles from one or more principals. + * + * The Data Fabric API removes all role assignments for each supplied external + * ID. Use this when a principal should no longer have direct Data Fabric + * access. Inherited access through groups is not changed. + * + * @param principalIds - Principal external ID or IDs + * @returns Promise resolving when the roles are revoked + * + * @example + * ```typescript + * import { DataFabricDirectoryService } from '@uipath/uipath-typescript/entities'; + * + * const directory = new DataFabricDirectoryService(sdk); + * + * await directory.revokeRoles(''); + * ``` + * + * @example + * ```typescript + * await directory.revokeRoles([ + * '', + * '', + * ]); + * ``` + * + * @internal + */ + @track('DataFabricDirectory.RevokeRoles') + async revokeRoles(principalIds: string | string[]): Promise { + const normalizedPrincipalIds = normalizePrincipalIds(principalIds); + if (normalizedPrincipalIds.length === 0) { + throw new ValidationError({ message: 'At least one principal ID is required.' }); + } + + const payload: DataFabricDirectoryRevokePayload = { + externalIds: normalizedPrincipalIds, + }; + await this.post(DATA_FABRIC_ENDPOINTS.DIRECTORY.REVOKE_ROLES, payload); + } +} diff --git a/src/services/data-fabric/index.ts b/src/services/data-fabric/index.ts index 135c2c249..9157f66b9 100644 --- a/src/services/data-fabric/index.ts +++ b/src/services/data-fabric/index.ts @@ -1,12 +1,16 @@ /** * Data Fabric Services Module * - * Provides access to UiPath Data Fabric services for entity and choice set management. + * Provides access to UiPath Data Fabric services for entity and choice set + * operations. * * @example * ```typescript * import { UiPath } from '@uipath/uipath-typescript/core'; - * import { Entities, ChoiceSets } from '@uipath/uipath-typescript/entities'; + * import { + * ChoiceSets, + * Entities, + * } from '@uipath/uipath-typescript/entities'; * * const sdk = new UiPath(config); * await sdk.initialize(); @@ -24,6 +28,8 @@ // Export service with cleaner name and keep EntityService for legacy UiPath class export { EntityService as Entities, EntityService } from './entities'; export { ChoiceSetService as ChoiceSets, ChoiceSetService } from './choicesets'; +export { DataFabricRoleService } from './roles'; +export { DataFabricDirectoryService } from './directory'; // Re-export service-specific types export * from '../../models/data-fabric/entities.types'; diff --git a/src/services/data-fabric/roles.ts b/src/services/data-fabric/roles.ts new file mode 100644 index 000000000..ee9cf0663 --- /dev/null +++ b/src/services/data-fabric/roles.ts @@ -0,0 +1,91 @@ +import { ServerError } from '../../core/errors'; +import { track } from '../../core/telemetry'; +import { DataFabricRoleServiceModel } from '../../models/data-fabric/roles.models'; +import { + DataFabricRole, + DataFabricRoleGetAllOptions, + DataFabricRoleType, +} from '../../models/data-fabric/roles.types'; +import { FOLDER_KEY } from '../../utils/constants/headers'; +import { DATA_FABRIC_ENDPOINTS } from '../../utils/constants/endpoints/data-fabric'; +import { createHeaders } from '../../utils/http/headers'; +import { createParams } from '../../utils/http/params'; +import { BaseService } from '../base'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isDataFabricRole(value: unknown): value is DataFabricRole { + if (!isRecord(value)) { + return false; + } + const { id, name, type, directoryEntityCount, folderId } = value; + const hasValidDirectoryEntityCount = directoryEntityCount === undefined || + directoryEntityCount === null || + typeof directoryEntityCount === 'number'; + const hasValidFolderId = folderId === undefined || typeof folderId === 'string'; + return typeof id === 'string' && + typeof name === 'string' && + (type === DataFabricRoleType.System || type === DataFabricRoleType.UserDefined) && + hasValidDirectoryEntityCount && + hasValidFolderId; +} + +function validateRolesResponse(data: unknown): DataFabricRole[] { + if (Array.isArray(data) && data.every(isDataFabricRole)) { + return data; + } + throw new ServerError({ + message: 'Invalid Data Fabric roles response format.', + }); +} + +/** + * @internal + */ +export class DataFabricRoleService extends BaseService implements DataFabricRoleServiceModel { + /** + * Lists Data Fabric access roles. + * + * Returns tenant Data Fabric roles such as Admin, Designer, DataWriter, and + * DataReader. Role IDs from this method can be passed to + * `DataFabricDirectoryService.assignRoles()`. + * + * @param options - Optional query options + * @returns Promise resolving to an array of {@link DataFabricRole} + * + * @example + * ```typescript + * import { DataFabricRoleService } from '@uipath/uipath-typescript/entities'; + * + * const roles = new DataFabricRoleService(sdk); + * const allRoles = await roles.getAll(); + * const dataWriter = allRoles.find(role => role.name === 'DataWriter'); + * ``` + * + * @example + * ```typescript + * const rolesWithoutStats = await roles.getAll({ stats: false }); + * ``` + * + * @example + * ```typescript + * const folderRoles = await roles.getAll({ folderKey: '' }); + * ``` + * + * @internal + */ + @track('DataFabricRoles.GetAll') + async getAll(options: DataFabricRoleGetAllOptions = {}): Promise { + const params = createParams({ + stats: options.stats ?? true, + }); + const headers = createHeaders({ [FOLDER_KEY]: options.folderKey }); + const response = await this.get( + DATA_FABRIC_ENDPOINTS.ROLES.GET_ALL, + { params, headers } + ); + return validateRolesResponse(response.data); + } +} diff --git a/src/uipath.ts b/src/uipath.ts index 836eeb0f2..397e5edba 100644 --- a/src/uipath.ts +++ b/src/uipath.ts @@ -7,6 +7,8 @@ import { CaseInstancesService, EntityService, ChoiceSetService, + DataFabricDirectoryService, + DataFabricRoleService, TaskService, ProcessService, BucketService, @@ -98,7 +100,19 @@ export class UiPath extends UiPathCore { /** * Access to ChoiceSet service for managing choice sets */ - choicesets: this.getService(ChoiceSetService) + choicesets: this.getService(ChoiceSetService), + /** + * Access to Data Fabric roles for manage-access flows + * + * @internal + */ + roles: this.getService(DataFabricRoleService), + /** + * Access to Data Fabric directory principals and role assignments + * + * @internal + */ + directory: this.getService(DataFabricDirectoryService) }); } diff --git a/src/utils/constants/endpoints/data-fabric.ts b/src/utils/constants/endpoints/data-fabric.ts index 727220619..893a9faa0 100644 --- a/src/utils/constants/endpoints/data-fabric.ts +++ b/src/utils/constants/endpoints/data-fabric.ts @@ -52,4 +52,12 @@ export const DATA_FABRIC_ENDPOINTS = { UPDATE_BY_NAME: (choiceSetName: string, valueId: string) => `${DATAFABRIC_BASE}/api/EntityService/${choiceSetName}/choiceset/${valueId}/update`, DELETE_BY_ID: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${choiceSetId}/choiceset/delete`, }, + ROLES: { + GET_ALL: `${DATAFABRIC_BASE}/api/v2/Role`, + }, + DIRECTORY: { + GET_ALL: `${DATAFABRIC_BASE}/api/Directory`, + ASSIGN_ROLES: `${DATAFABRIC_BASE}/api/Directory/Role`, + REVOKE_ROLES: `${DATAFABRIC_BASE}/api/Directory/RevokeRole`, + }, } as const; diff --git a/src/utils/pagination/helpers.ts b/src/utils/pagination/helpers.ts index 412b52458..011aacc84 100644 --- a/src/utils/pagination/helpers.ts +++ b/src/utils/pagination/helpers.ts @@ -364,4 +364,4 @@ export class PaginationHelpers { } }) as any; } -} \ No newline at end of file +} diff --git a/src/utils/pagination/internal-types.ts b/src/utils/pagination/internal-types.ts index 6b0ed36af..38c8f2fab 100644 --- a/src/utils/pagination/internal-types.ts +++ b/src/utils/pagination/internal-types.ts @@ -252,4 +252,4 @@ export interface GetAllConfig { /** Pre-resolved request headers. Overrides the helper's auto-built folder header from `folderId`. */ headers?: Record; -} \ No newline at end of file +} diff --git a/tests/.env.integration.example b/tests/.env.integration.example index 3e8278f86..46b2af814 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -73,4 +73,4 @@ TASKS_TEST_USER_GROUP_ID= # User ID (from tasks.getUsers()) used by the single-user task assignment tests. # The user must have task permissions in the folder identified by INTEGRATION_TEST_FOLDER_ID. -TASKS_TEST_USER_ID= \ No newline at end of file +TASKS_TEST_USER_ID= diff --git a/tests/integration/README.md b/tests/integration/README.md index 94e364535..74dc7062f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -51,7 +51,8 @@ tests/integration/ │ │ └── tasks.integration.test.ts │ └── data-fabric/ # Data Fabric service tests │ ├── entities.integration.test.ts -│ └── choicesets.integration.test.ts +│ ├── choicesets.integration.test.ts +│ └── access.integration.test.ts └── auth-errors.integration.test.ts # Authentication & authorization error tests ``` @@ -196,6 +197,7 @@ These services do not support create/update/delete via SDK: #### Data Fabric Services (Full CRUD) - **Entities**: Complete CRUD operations for entity records - **ChoiceSets**: Read operations for choice sets +- **Access**: Skipped tests for Data Fabric role listing, directory principals, and assignment validation until the CI app has the required DataFabric scopes #### Action Center Services - **Tasks**: Create, list, get by ID, assign, unassign, complete diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 1b6eed731..defc374d8 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -1,5 +1,10 @@ import { UiPath } from '../../../src/core'; -import { Entities, ChoiceSets } from '../../../src/services/data-fabric'; +import { + ChoiceSets, + DataFabricDirectoryService, + DataFabricRoleService, + Entities, +} from '../../../src/services/data-fabric'; import { Tasks } from '../../../src/services/action-center'; import { Assets, Buckets, Jobs, Queues, Processes } from '../../../src/services/orchestrator'; import { AttachmentService as Attachments } from '../../../src/services/orchestrator/attachments'; @@ -38,6 +43,8 @@ export interface TestServices { sdk: UiPath; entities: Entities; choiceSets: ChoiceSets; + dataFabricRoles: DataFabricRoleService; + dataFabricDirectory: DataFabricDirectoryService; tasks: Tasks; assets: Assets; buckets: Buckets; @@ -90,6 +97,8 @@ function createV0Services(config: IntegrationConfig): TestServices { sdk: sdk as unknown as UiPath, entities: sdk.entities as unknown as Entities, choiceSets: sdk.entities.choicesets as unknown as ChoiceSets, + dataFabricRoles: sdk.entities.roles as unknown as DataFabricRoleService, + dataFabricDirectory: sdk.entities.directory as unknown as DataFabricDirectoryService, tasks: sdk.tasks as unknown as Tasks, assets: sdk.assets as unknown as Assets, buckets: sdk.buckets as unknown as Buckets, @@ -123,6 +132,8 @@ function createV1Services(config: IntegrationConfig): TestServices { sdk, entities: new Entities(sdk), choiceSets: new ChoiceSets(sdk), + dataFabricRoles: new DataFabricRoleService(sdk), + dataFabricDirectory: new DataFabricDirectoryService(sdk), tasks: new Tasks(sdk), assets: new Assets(sdk), buckets: new Buckets(sdk), diff --git a/tests/integration/shared/data-fabric/access.integration.test.ts b/tests/integration/shared/data-fabric/access.integration.test.ts new file mode 100644 index 000000000..0f1f9a6ec --- /dev/null +++ b/tests/integration/shared/data-fabric/access.integration.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { + getServices, + setupUnifiedTests, + InitMode, +} from '../../config/unified-setup'; + +const modes: InitMode[] = ['v0', 'v1']; + +// Skipped: Data Fabric role and directory APIs require DataFabric.Data.Read +// and DataFabric.Data.Write scopes on the test external app/PAT. The standard +// CI tenant currently returns 403 for these APIs. +describe.skip.each(modes)('Data Fabric Access - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + describe('roles.getAll', () => { + it('should retrieve Data Fabric roles', async () => { + const { dataFabricRoles } = getServices(); + + const roles = await dataFabricRoles.getAll(); + + expect(Array.isArray(roles)).toBe(true); + expect(roles.length).toBeGreaterThan(0); + + const role = roles[0]; + expect(role.id).toBeDefined(); + expect(role.name).toBeDefined(); + expect(typeof role.id).toBe('string'); + expect(typeof role.name).toBe('string'); + }); + + it('should support disabling role stats', async () => { + const { dataFabricRoles } = getServices(); + + const roles = await dataFabricRoles.getAll({ stats: false }); + + expect(Array.isArray(roles)).toBe(true); + }); + }); + + describe('directory.list', () => { + it('should retrieve one page of Data Fabric directory principals', async () => { + const { dataFabricDirectory } = getServices(); + + const page = await dataFabricDirectory.list({ skip: 0, top: 1 }); + + expect(page).toBeDefined(); + expect(Array.isArray(page.results)).toBe(true); + expect(page.results.length).toBeLessThanOrEqual(1); + + expect(typeof page.totalCount).toBe('number'); + + if (page.results.length > 0) { + const principal = page.results[0]; + expect(principal.externalId).toBeDefined(); + expect(typeof principal.externalId).toBe('string'); + expect(Array.isArray(principal.roles)).toBe(true); + } + }); + }); + + describe('directory.getAll', () => { + it('should retrieve all Data Fabric directory principals', async () => { + const { dataFabricDirectory } = getServices(); + + const principals = await dataFabricDirectory.getAll({ pageSize: 100 }); + + expect(Array.isArray(principals)).toBe(true); + + if (principals.length > 0) { + const principal = principals[0]; + expect(principal.externalId).toBeDefined(); + expect(typeof principal.externalId).toBe('string'); + expect(Array.isArray(principal.roles)).toBe(true); + } + }); + }); + +}); diff --git a/tests/unit/legacy/uipath-legacy.test.ts b/tests/unit/legacy/uipath-legacy.test.ts index a9684fe2f..00ddfbbbc 100644 --- a/tests/unit/legacy/uipath-legacy.test.ts +++ b/tests/unit/legacy/uipath-legacy.test.ts @@ -9,6 +9,9 @@ import { CasesService, CaseInstancesService, EntityService, + ChoiceSetService, + DataFabricDirectoryService, + DataFabricRoleService, TaskService, ProcessService, BucketService, @@ -133,6 +136,9 @@ describe('UiPath Legacy Pattern', () => { expect(entities).toBeDefined(); expect(entities).toBeInstanceOf(EntityService); expect(entities.getAll).toBeDefined(); + expect(entities.choicesets).toBeInstanceOf(ChoiceSetService); + expect(entities.roles).toBeInstanceOf(DataFabricRoleService); + expect(entities.directory).toBeInstanceOf(DataFabricDirectoryService); }); }); diff --git a/tests/unit/services/data-fabric/directory.test.ts b/tests/unit/services/data-fabric/directory.test.ts new file mode 100644 index 000000000..ef34dedfb --- /dev/null +++ b/tests/unit/services/data-fabric/directory.test.ts @@ -0,0 +1,474 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ServerError, ValidationError } from '../../../../src/core/errors'; +import { DataFabricDirectoryService } from '../../../../src/services/data-fabric/directory'; +import { + DataFabricDirectoryEntityType, + DataFabricDirectoryEntityTypeName, +} from '../../../../src/models/data-fabric/directory.types'; +import { DATA_FABRIC_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { + createMockApiClient, + createServiceTestDependencies, +} from '../../../utils/setup'; +import { TEST_CONSTANTS } from '../../../utils/constants'; + +vi.mock('../../../../src/core/http/api-client'); + +describe('DataFabricDirectoryService Unit Tests', () => { + let directoryService: DataFabricDirectoryService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + directoryService = new DataFabricDirectoryService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('list', () => { + it('should list Data Fabric directory entries', async () => { + mockApiClient.get.mockResolvedValue({ + totalCount: 1, + results: [ + { + externalId: 'group-id', + name: 'MRSAdmin', + type: 'Group', + roles: [{ id: 'role-existing', name: 'Data Reader' }], + isUIEnabled: true, + }, + ], + }); + + const result = await directoryService.list({ skip: 10, top: 25 }); + + expect(result).toEqual({ + totalCount: 1, + results: [ + { + externalId: 'group-id', + name: 'MRSAdmin', + type: 'Group', + roles: [{ id: 'role-existing', name: 'Data Reader' }], + isUIEnabled: true, + }, + ], + }); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.GET_ALL, + { params: { skip: 10, top: 25 } } + ); + }); + + it('should default missing roles in directory entries', async () => { + mockApiClient.get.mockResolvedValue({ + totalCount: 1, + results: [ + { + externalId: 'user-id', + name: 'User Name', + type: 'User', + }, + ], + }); + + const result = await directoryService.list(); + + expect(result).toEqual({ + totalCount: 1, + results: [ + { + externalId: 'user-id', + name: 'User Name', + type: 'User', + roles: [], + isUIEnabled: true, + }, + ], + }); + }); + + it('should clamp top to the maximum directory page size', async () => { + mockApiClient.get.mockResolvedValue({ totalCount: 0, results: [] }); + + await directoryService.list({ top: 500 }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.GET_ALL, + { params: { top: 100 } } + ); + }); + + it('should reject invalid directory response formats', async () => { + mockApiClient.get.mockResolvedValue({ value: [] }); + + const result = directoryService.list(); + + await expect(result).rejects.toBeInstanceOf(ServerError); + await expect(result).rejects.toThrow( + 'Invalid Data Fabric directory response format.' + ); + }); + + it('should reject invalid directory entry response formats', async () => { + mockApiClient.get.mockResolvedValue({ + totalCount: 1, + results: [{ externalId: 'user-id', name: 'User Name', type: 'DirectoryRobot' }], + }); + + const result = directoryService.list(); + + await expect(result).rejects.toBeInstanceOf(ServerError); + await expect(result).rejects.toThrow( + 'Invalid Data Fabric directory entry response format.' + ); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(directoryService.list()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('getAll', () => { + it('should page through all directory entries', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + externalId: `principal-${index}`, + name: `Principal ${index}`, + type: 'User' as const, + roles: [], + })); + mockApiClient.get + .mockResolvedValueOnce({ totalCount: 101, results: firstPage }) + .mockResolvedValueOnce({ + totalCount: 101, + results: [{ externalId: 'principal-100', name: 'Principal 100', type: 'User', roles: [] }], + }); + + const result = await directoryService.getAll(); + + expect(result).toHaveLength(101); + expect(mockApiClient.get).toHaveBeenNthCalledWith( + 1, + DATA_FABRIC_ENDPOINTS.DIRECTORY.GET_ALL, + expect.objectContaining({ params: { top: 100 } }) + ); + expect(mockApiClient.get).toHaveBeenNthCalledWith( + 2, + DATA_FABRIC_ENDPOINTS.DIRECTORY.GET_ALL, + expect.objectContaining({ params: { top: 100, skip: 100 } }) + ); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(directoryService.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('assignRoles', () => { + it('should preserve existing roles by default before assigning', async () => { + mockApiClient.get.mockResolvedValue({ + totalCount: 1, + results: [ + { + externalId: 'group-id', + name: 'MRSAdmin', + type: 'Group', + roles: [{ id: 'role-existing', name: 'Data Reader' }], + }, + ], + }); + mockApiClient.post.mockResolvedValue(true); + + const result = await directoryService.assignRoles( + 'group-id', + DataFabricDirectoryEntityTypeName.Group, + ['role-existing', 'role-new'] + ); + + expect(result).toEqual([ + { + principalId: 'group-id', + roleIds: ['role-existing', 'role-new'], + }, + ]); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, + { + directoryEntities: [ + { + externalId: 'group-id', + type: DataFabricDirectoryEntityType.Group, + resolved: true, + }, + ], + roles: ['role-existing', 'role-new'], + isUIEnabled: true, + }, + {} + ); + }); + + it('should preserve existing roles when the principal is not yet in the directory', async () => { + mockApiClient.get.mockResolvedValue({ + totalCount: 0, + results: [], + }); + mockApiClient.post.mockResolvedValue(true); + + const result = await directoryService.assignRoles( + 'new-group-id', + DataFabricDirectoryEntityTypeName.Group, + ['role-new'] + ); + + expect(result).toEqual([ + { + principalId: 'new-group-id', + roleIds: ['role-new'], + }, + ]); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, + { + directoryEntities: [ + { + externalId: 'new-group-id', + type: DataFabricDirectoryEntityType.Group, + resolved: true, + }, + ], + roles: ['role-new'], + isUIEnabled: true, + }, + {} + ); + }); + + it('should support replace-style assignment when preserveExisting is false', async () => { + mockApiClient.post.mockResolvedValue(true); + + const result = await directoryService.assignRoles( + ['user-id'], + DataFabricDirectoryEntityTypeName.User, + ['role-new'], + { preserveExisting: false, uiEnabled: false } + ); + + expect(result).toEqual([ + { + principalId: 'user-id', + roleIds: ['role-new'], + }, + ]); + expect(mockApiClient.get).not.toHaveBeenCalled(); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, + { + directoryEntities: [ + { + externalId: 'user-id', + type: DataFabricDirectoryEntityType.User, + resolved: true, + }, + ], + roles: ['role-new'], + isUIEnabled: false, + }, + {} + ); + }); + + it('should assign roles to multiple principals', async () => { + mockApiClient.post.mockResolvedValue(true); + + const result = await directoryService.assignRoles( + ['group-id', 'group-id-2'], + DataFabricDirectoryEntityTypeName.Group, + ['role-new'], + { preserveExisting: false } + ); + + expect(result).toEqual([ + { + principalId: 'group-id', + roleIds: ['role-new'], + }, + { + principalId: 'group-id-2', + roleIds: ['role-new'], + }, + ]); + expect(mockApiClient.post).toHaveBeenCalledTimes(2); + }); + + it('should support application principal types', async () => { + mockApiClient.post.mockResolvedValue(true); + + await directoryService.assignRoles( + 'application-id', + DataFabricDirectoryEntityTypeName.Application, + ['role-new'], + { preserveExisting: false } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, + expect.objectContaining({ + directoryEntities: [ + { + externalId: 'application-id', + type: DataFabricDirectoryEntityType.Application, + resolved: true, + }, + ], + }), + {} + ); + }); + + it('should support numeric principal types', async () => { + mockApiClient.post.mockResolvedValue(true); + + await directoryService.assignRoles( + 'user-id', + DataFabricDirectoryEntityType.User, + ['role-new'], + { preserveExisting: false } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.ASSIGN_ROLES, + expect.objectContaining({ + directoryEntities: [ + { + externalId: 'user-id', + type: DataFabricDirectoryEntityType.User, + resolved: true, + }, + ], + }), + {} + ); + }); + + it('should reject invalid numeric principal types', async () => { + await expect( + directoryService.assignRoles( + 'group-id', + 99 as DataFabricDirectoryEntityType, + ['role-new'] + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('should reject empty principal IDs', async () => { + await expect( + directoryService.assignRoles( + [' '], + DataFabricDirectoryEntityTypeName.Group, + ['role-new'] + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('should reject empty role IDs', async () => { + await expect( + directoryService.assignRoles( + 'group-id', + DataFabricDirectoryEntityTypeName.Group, + [''] + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('should reject invalid principal types', async () => { + await expect( + directoryService.assignRoles( + 'group-id', + 'Team' as never, + ['role-new'] + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('should propagate errors from the POST call', async () => { + mockApiClient.post.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + directoryService.assignRoles( + 'group-id', + DataFabricDirectoryEntityTypeName.Group, + ['role-new'], + { preserveExisting: false } + ) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('should propagate errors from fetching existing entries', async () => { + mockApiClient.get.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + directoryService.assignRoles( + 'group-id', + DataFabricDirectoryEntityTypeName.Group, + ['role-new'] + ) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + }); + + describe('revokeRoles', () => { + it('should revoke roles for one principal', async () => { + mockApiClient.post.mockResolvedValue(true); + + await directoryService.revokeRoles('user-id'); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.REVOKE_ROLES, + { + externalIds: ['user-id'], + }, + {} + ); + }); + + it('should trim, dedupe, and revoke roles for multiple principals', async () => { + mockApiClient.post.mockResolvedValue(true); + + await directoryService.revokeRoles([' user-id ', 'group-id', 'user-id']); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.DIRECTORY.REVOKE_ROLES, + { + externalIds: ['user-id', 'group-id'], + }, + {} + ); + }); + + it('should reject empty principal IDs', async () => { + await expect( + directoryService.revokeRoles([' ']) + ).rejects.toBeInstanceOf(ValidationError); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('should propagate API errors', async () => { + mockApiClient.post.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + directoryService.revokeRoles('user-id') + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); +}); diff --git a/tests/unit/services/data-fabric/index.ts b/tests/unit/services/data-fabric/index.ts index ea061d20a..4cc8aee07 100644 --- a/tests/unit/services/data-fabric/index.ts +++ b/tests/unit/services/data-fabric/index.ts @@ -3,4 +3,3 @@ */ export * from './entities.test'; - diff --git a/tests/unit/services/data-fabric/roles.test.ts b/tests/unit/services/data-fabric/roles.test.ts new file mode 100644 index 000000000..9ae667054 --- /dev/null +++ b/tests/unit/services/data-fabric/roles.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ServerError } from '../../../../src/core/errors'; +import { DataFabricRoleService } from '../../../../src/services/data-fabric/roles'; +import { DATA_FABRIC_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { + createMockApiClient, + createServiceTestDependencies, +} from '../../../utils/setup'; +import { TEST_CONSTANTS } from '../../../utils/constants'; + +vi.mock('../../../../src/core/http/api-client'); + +describe('DataFabricRoleService Unit Tests', () => { + let rolesService: DataFabricRoleService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + rolesService = new DataFabricRoleService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should list Data Fabric roles with stats enabled by default', async () => { + mockApiClient.get.mockResolvedValue([ + { id: 'role-1', name: 'Administrator', type: 'System', directoryEntityCount: 2 }, + { id: 'role-2', name: 'Data Writer', type: 'System', directoryEntityCount: 4 }, + ]); + + const result = await rolesService.getAll(); + + expect(result).toEqual([ + { id: 'role-1', name: 'Administrator', type: 'System', directoryEntityCount: 2 }, + { id: 'role-2', name: 'Data Writer', type: 'System', directoryEntityCount: 4 }, + ]); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ROLES.GET_ALL, + { params: { stats: true }, headers: {} } + ); + }); + + it('should support disabling role stats', async () => { + mockApiClient.get.mockResolvedValue([ + { id: 'role-1', name: 'Administrator', type: 'System' }, + ]); + + const result = await rolesService.getAll({ stats: false }); + + expect(result).toEqual([{ id: 'role-1', name: 'Administrator', type: 'System' }]); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ROLES.GET_ALL, + { params: { stats: false }, headers: {} } + ); + }); + + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.get.mockResolvedValue([ + { id: 'role-1', name: 'Folder Role', type: 'UserDefined', folderId: 'folder-key' }, + ]); + + const result = await rolesService.getAll({ folderKey: 'folder-key' }); + + expect(result).toEqual([ + { id: 'role-1', name: 'Folder Role', type: 'UserDefined', folderId: 'folder-key' }, + ]); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ROLES.GET_ALL, + { + params: { stats: true }, + headers: { 'X-UIPATH-FolderKey': 'folder-key' }, + } + ); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(rolesService.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('should reject invalid role response formats', async () => { + mockApiClient.get.mockResolvedValue({ results: [] }); + const result = rolesService.getAll(); + + await expect(result).rejects.toBeInstanceOf(ServerError); + await expect(result).rejects.toThrow( + 'Invalid Data Fabric roles response format.' + ); + }); + }); +});