diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index cb577ce24..29eefa683 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -77,6 +77,7 @@ jobs: echo "tasks_test_user_group_id=${{ secrets.UIPATH_TASKS_TEST_USER_GROUP_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_GROUP_ID }}" >> $GITHUB_OUTPUT echo "tasks_test_user_id=${{ secrets.UIPATH_TASKS_TEST_USER_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_ID }}" >> $GITHUB_OUTPUT echo "organization_id=${{ secrets.UIPATH_ORGANIZATION_ID_DEV || secrets.UIPATH_ORGANIZATION_ID }}" >> $GITHUB_OUTPUT + echo "portal_url=${{ secrets.UIPATH_PORTAL_URL_DEV || secrets.UIPATH_PORTAL_URL }}" >> $GITHUB_OUTPUT - name: Create Integration Test Configuration run: | @@ -107,6 +108,7 @@ jobs: TASKS_TEST_USER_GROUP_ID=${{ steps.config.outputs.tasks_test_user_group_id }} TASKS_TEST_USER_ID=${{ steps.config.outputs.tasks_test_user_id }} UIPATH_ORGANIZATION_ID=${{ steps.config.outputs.organization_id }} + UIPATH_PORTAL_URL=${{ steps.config.outputs.portal_url }} EOF - name: Run Integration Tests diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 6b6429411..415a3c73d 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -283,3 +283,4 @@ User management is authorized by the caller's **organization role** (organizatio | `updateById()` | None — requires organization administrator role | | `deleteById()` | None — requires organization administrator role | | `create()` | None — requires organization administrator role | +| `invite()` | None — requires organization administrator role | diff --git a/src/models/identity/users.constants.ts b/src/models/identity/users.constants.ts index 43062ed73..5ba1ee0ad 100644 --- a/src/models/identity/users.constants.ts +++ b/src/models/identity/users.constants.ts @@ -15,6 +15,13 @@ export const UserMap: { [key: string]: string } = { groupIDsToRemove: 'groupIdsToRemove', }; +/** + * Invite result field mappings (API field name → SDK field name). + */ +export const UserInviteResultMap: { [key: string]: string } = { + errorMsg: 'errorMessage', +}; + /** * Maps numeric user type codes (from API) to {@link UserType} enum values. */ diff --git a/src/models/identity/users.internal-types.ts b/src/models/identity/users.internal-types.ts index d1fdf7407..cf09c65dd 100644 --- a/src/models/identity/users.internal-types.ts +++ b/src/models/identity/users.internal-types.ts @@ -59,3 +59,22 @@ export interface RawUserCreateResponse { result: UserOperationResult; users: RawUserEntry[]; } + +/** + * Raw per-user result entry of `POST /api/User/InviteUsers`. + */ +export interface RawUserInviteResult { + email: string; + id: string; + /** Renamed to `errorMessage` in the SDK response. */ + errorMsg: string | null; + success: boolean; +} + +/** + * Raw response of `POST /api/User/InviteUsers`. + */ +export interface RawUserInviteResponse { + result: UserOperationResult; + users: RawUserInviteResult[]; +} diff --git a/src/models/identity/users.models.ts b/src/models/identity/users.models.ts index 65170d48f..3c44ec793 100644 --- a/src/models/identity/users.models.ts +++ b/src/models/identity/users.models.ts @@ -7,6 +7,8 @@ import type { RawUserGetResponse, UserCreateData, UserCreateOptions, + UserInviteData, + UserInviteResponse, UserOperationResult, UserUpdateOptions, UserUpdateResponse, @@ -144,6 +146,44 @@ export interface UserServiceModel { organizationId: string, options?: UserCreateOptions ): Promise; + + /** + * Invites users to the organization by email. + * + * Each invited user receives an invitation email with an accept link that + * redirects to `redirectUrl`. Individual invitations can fail while the + * request as a whole succeeds — check `success` on each entry in `users`. + * + * @param users - Users to invite + * @returns Promise resolving to a {@link UserInviteResponse} with the overall outcome and per-user invitation results + * + * @example + * ```typescript + * const response = await users.invite([ + * { + * email: 'jdoe@acme.com', + * redirectUrl: 'https://cloud.uipath.com/portal_/acceptInvite?organizationId=', + * }, + * ]); + * for (const user of response.users) { + * console.log(`${user.email}: ${user.success ? 'invited' : user.errorMessage}`); + * } + * ``` + * + * @example Invite into groups + * ```typescript + * await users.invite([ + * { + * email: 'jdoe@acme.com', + * redirectUrl: 'https://cloud.uipath.com/portal_/acceptInvite?organizationId=', + * name: 'Jane', + * surname: 'Doe', + * groupIds: [''], + * }, + * ]); + * ``` + */ + invite(users: UserInviteData[]): Promise; } /** diff --git a/src/models/identity/users.types.ts b/src/models/identity/users.types.ts index e2694b61f..95a64930d 100644 --- a/src/models/identity/users.types.ts +++ b/src/models/identity/users.types.ts @@ -152,3 +152,51 @@ export interface UserCreateOptions { groupIds?: string[]; } +/** + * A user to invite with `invite()`. + */ +export interface UserInviteData { + /** Email address the invitation is sent to. */ + email: string; + /** + * URL the accept-invite link redirects to. Must be an allowed UiPath portal URL for + * the organization, e.g. `https://cloud.uipath.com/portal_/acceptInvite?organizationId=...` + * — other URLs are rejected per user with `Redirect URL is not valid`. + */ + redirectUrl: string; + /** First name. */ + name?: string; + /** Last name. */ + surname?: string; + /** Language code for the invitation email (e.g. `en`). */ + language?: string; + /** GUIDs of groups the invited user is added to. */ + groupIds?: string[]; +} + +/** + * Per-user outcome of `invite()`. + */ +export interface UserInviteResult { + /** Email address the invitation was sent to. */ + email: string; + /** GUID of the newly invited user. All-zeros GUID when the invitation failed. */ + id: string; + /** Why the invitation failed. `null` on success. */ + errorMessage: string | null; + /** Whether this user was successfully invited. */ + success: boolean; +} + +/** + * Response from `invite()`. + * + * `result` reflects the request as a whole; individual invitations can still fail — + * check `success` on each entry in `users`. + */ +export interface UserInviteResponse { + /** Overall outcome of the invite request. */ + result: UserOperationResult; + /** Per-user invitation outcomes. */ + users: UserInviteResult[]; +} diff --git a/src/services/identity/index.ts b/src/services/identity/index.ts index 5ff724920..fc34ff3f0 100644 --- a/src/services/identity/index.ts +++ b/src/services/identity/index.ts @@ -2,7 +2,8 @@ * Identity Users Service Module * * Provides organization-level user administration via the UiPath Identity API: - * - `Users` — retrieve, update and delete users, and create users in bulk + * - `Users` — retrieve, update and delete users, create users in bulk, and + * invite users to the organization by email * * @example * ```typescript diff --git a/src/services/identity/users.ts b/src/services/identity/users.ts index 2f4df7ef0..df7ba17d6 100644 --- a/src/services/identity/users.ts +++ b/src/services/identity/users.ts @@ -9,6 +9,9 @@ import type { RawUserGetResponse, UserCreateData, UserCreateOptions, + UserInviteData, + UserInviteResponse, + UserInviteResult, UserUpdateOptions, UserUpdateResponse, } from '../../models/identity/users.types'; @@ -22,9 +25,11 @@ import { INTERNAL_USER_FIELDS, type RawUserCreateResponse, type RawUserEntry, + type RawUserInviteResponse, } from '../../models/identity/users.internal-types'; import { UserCategoryMap, + UserInviteResultMap, UserMap, UserTypeMap, } from '../../models/identity/users.constants'; @@ -78,6 +83,18 @@ export class UserService extends BaseService implements UserServiceModel { }; } + @track('Users.Invite') + async invite(users: UserInviteData[]): Promise { + const response = await this.post( + IDENTITY_USER_ENDPOINTS.INVITE, + users.map((user) => transformRequest(user, UserMap)) + ); + return { + result: response.data.result, + users: transformData(response.data.users, UserInviteResultMap) as unknown as UserInviteResult[], + }; + } + /** * Strips internal fields from a raw user entry and applies the SDK field * renames and numeric → enum value mappings before returning it to the consumer. diff --git a/src/utils/constants/endpoints/identity.ts b/src/utils/constants/endpoints/identity.ts index 232a68e95..aaf901a28 100644 --- a/src/utils/constants/endpoints/identity.ts +++ b/src/utils/constants/endpoints/identity.ts @@ -24,4 +24,5 @@ export const IDENTITY_USER_ENDPOINTS = { /** GET (retrieve), PUT (update) and DELETE share this URL — the HTTP method is chosen at the call site. */ BY_ID: (userId: string) => `${USER_API_BASE}/${userId}`, BULK_CREATE: `${USER_API_BASE}/BulkCreate`, + INVITE: `${USER_API_BASE}/InviteUsers`, } as const; diff --git a/tests/.env.integration.example b/tests/.env.integration.example index 2f61e80f4..dafb850f3 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -86,5 +86,10 @@ TASKS_TEST_USER_GROUP_ID= TASKS_TEST_USER_ID= # Organization GUID (from UiPath Cloud > Admin > Organization Settings) -# Required by the Identity Users tests (user create operations) +# Required by the Identity Users tests (user create/invite operations) UIPATH_ORGANIZATION_ID=3aa10965-a82d-4d9e-8366-0eff8e87bf7a + +# Portal host for invite accept-links (e.g. https://cloud.uipath.com). +# Only needed when UIPATH_BASE_URL points at the API gateway host — invite +# redirect URLs must use the portal host. Defaults to UIPATH_BASE_URL. +UIPATH_PORTAL_URL= diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index 576b0bf13..c889c9f8d 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -14,6 +14,12 @@ export interface IntegrationConfig { * as the partition the users belong to, and invite redirect URLs embed it). */ organizationId?: string; + /** + * Portal host for invite accept-links (e.g. `https://alpha.uipath.com`). Invite + * redirect URLs must point at the portal host — the API gateway host is rejected + * with `Redirect URL is not valid`. Defaults to `baseUrl` when they coincide. + */ + portalUrl?: string; secret: string; timeout: number; skipCleanup: boolean; @@ -85,6 +91,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { tenantName: rawConfig.tenantName as string, tenantId: typeof rawConfig.tenantId === 'string' ? rawConfig.tenantId : undefined, organizationId: typeof rawConfig.organizationId === 'string' ? rawConfig.organizationId : undefined, + portalUrl: typeof rawConfig.portalUrl === 'string' ? rawConfig.portalUrl : undefined, secret: rawConfig.secret as string, timeout: typeof rawConfig.timeout === 'number' && rawConfig.timeout > 0 ? rawConfig.timeout : 30000, skipCleanup: typeof rawConfig.skipCleanup === 'boolean' ? rawConfig.skipCleanup : false, @@ -129,6 +136,7 @@ export function loadIntegrationConfig(): IntegrationConfig { tenantName: process.env.UIPATH_TENANT_NAME, tenantId: process.env.UIPATH_TENANT_ID_DEV || undefined, organizationId: process.env.UIPATH_ORGANIZATION_ID || undefined, + portalUrl: process.env.UIPATH_PORTAL_URL || undefined, secret: process.env.UIPATH_SECRET, timeout: process.env.INTEGRATION_TEST_TIMEOUT ? parseInt(process.env.INTEGRATION_TEST_TIMEOUT, 10) diff --git a/tests/integration/shared/identity/users.integration.test.ts b/tests/integration/shared/identity/users.integration.test.ts index 4ae5049aa..4fd467e9a 100644 --- a/tests/integration/shared/identity/users.integration.test.ts +++ b/tests/integration/shared/identity/users.integration.test.ts @@ -17,6 +17,22 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => { let sharedUserName!: string; const createdUserIds: string[] = []; + /** + * Invite redirect URLs must be an allowed portal URL for the organization — + * anything else (including the API gateway host) fails per-user with + * `Redirect URL is not valid`. + */ + const buildRedirectUrl = (email: string): string => { + const config = getTestConfig(); + const params = new URLSearchParams({ + organizationId, + emailForUserinvite: email, + organizationName: config.orgName, + language: 'en', + }); + return `${config.portalUrl ?? config.baseUrl}/portal_/acceptInvite?${params.toString()}`; + }; + beforeAll(async () => { const service = getServices().users; if (!service) { @@ -134,6 +150,54 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => { }); }); + describe('invite', () => { + it('should invite a user by email and report the per-user outcome', async () => { + // example.com is reserved (RFC 2606) — the invitation email goes nowhere. + const email = `sdk-invite-${generateRandomString(10)}@example.com`; + + const response = await users.invite([ + { + email, + redirectUrl: buildRedirectUrl(email), + name: 'Sdk', + surname: 'Invited', + language: 'en', + }, + ]); + + expect(response.result.succeeded).toBe(true); + expect(response.users.length).toBe(1); + + const invited = response.users[0]; + expect(invited.email).toBe(email); + expect(invited.success).toBe(true); + expect(invited.errorMessage).toBeNull(); + expect(invited.id.length).toBeGreaterThan(0); + + createdUserIds.push(invited.id); + + // The invited user is a real user retrievable by ID with the invite pending. + const user = await users.getById(invited.id); + expect(user.email).toBe(email); + expect(user.invitationAccepted).toBe(false); + }); + + it('should report a per-user failure for a disallowed redirect URL', async () => { + const email = `sdk-invite-${generateRandomString(10)}@example.com`; + + const response = await users.invite([ + { email, redirectUrl: 'https://not-a-uipath-portal.example.com/accept' }, + ]); + + // The request as a whole succeeds; the individual invitation fails. + expect(response.result.succeeded).toBe(true); + const invited = response.users[0]; + expect(invited.success).toBe(false); + expect(typeof invited.errorMessage).toBe('string'); + expect(invited).not.toHaveProperty('errorMsg'); + }); + }); + describe('deleteById', () => { it('should delete a user and make subsequent retrieval fail', async () => { const userName = `sdkit${generateRandomString(10)}`; diff --git a/tests/unit/services/identity/users.test.ts b/tests/unit/services/identity/users.test.ts index 6ffd043f6..b6bed1ee7 100644 --- a/tests/unit/services/identity/users.test.ts +++ b/tests/unit/services/identity/users.test.ts @@ -4,6 +4,7 @@ import { UserService } from '../../../../src/services/identity/users'; import { ApiClient } from '../../../../src/core/http/api-client'; import { createBasicRawUserEntry, + createBasicRawUserInviteResult, USER_TEST_CONSTANTS, createMockError, } from '../../../utils/mocks'; @@ -222,4 +223,67 @@ describe('UserService Unit Tests', () => { }); }); + describe('invite', () => { + it('should POST invited users with groupIds renamed to API names', async () => { + mockApiClient.post.mockResolvedValue({ + result: { succeeded: true, errors: [] }, + users: [createBasicRawUserInviteResult()], + }); + + const result = await userService.invite([ + { + email: USER_TEST_CONSTANTS.EMAIL, + redirectUrl: USER_TEST_CONSTANTS.REDIRECT_URL, + groupIds: [USER_TEST_CONSTANTS.GROUP_ID], + }, + ]); + + expect(mockApiClient.post).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.INVITE, + [ + { + email: USER_TEST_CONSTANTS.EMAIL, + redirectUrl: USER_TEST_CONSTANTS.REDIRECT_URL, + groupIDs: [USER_TEST_CONSTANTS.GROUP_ID], + }, + ], + expect.anything() + ); + expect(result.result.succeeded).toBe(true); + expect(result.users[0].success).toBe(true); + expect(result.users[0].id).toBe(USER_TEST_CONSTANTS.USER_ID); + }); + + it('should rename errorMsg to errorMessage on per-user results', async () => { + mockApiClient.post.mockResolvedValue({ + result: { succeeded: true, errors: [] }, + users: [ + createBasicRawUserInviteResult({ + id: USER_TEST_CONSTANTS.EMPTY_GUID, + errorMsg: USER_TEST_CONSTANTS.INVITE_ERROR_MESSAGE, + success: false, + }), + ], + }); + + const result = await userService.invite([ + { email: USER_TEST_CONSTANTS.EMAIL, redirectUrl: USER_TEST_CONSTANTS.REDIRECT_URL }, + ]); + + // Per-user failure surfaces via the renamed field + expect(result.users[0].success).toBe(false); + expect(result.users[0].errorMessage).toBe(USER_TEST_CONSTANTS.INVITE_ERROR_MESSAGE); + expect((result.users[0] as any).errorMsg).toBeUndefined(); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); + + await expect( + userService.invite([ + { email: USER_TEST_CONSTANTS.EMAIL, redirectUrl: USER_TEST_CONSTANTS.REDIRECT_URL }, + ]) + ).rejects.toThrow(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND); + }); + }); }); diff --git a/tests/utils/constants/users.ts b/tests/utils/constants/users.ts index 2dde8f27d..48540f5d3 100644 --- a/tests/utils/constants/users.ts +++ b/tests/utils/constants/users.ts @@ -28,6 +28,11 @@ export const USER_TEST_CONSTANTS = { // Internal API field dropped by the SDK LEGACY_ID: 1141202, + // Invite fields (mirrors real-API behaviour captured during onboarding) + REDIRECT_URL: 'https://alpha.uipath.com/portal_/acceptInvite?organizationId=', + INVITE_ERROR_MESSAGE: 'Redirect URL is not valid', + EMPTY_GUID: '00000000-0000-0000-0000-000000000000', + // Error messages ERROR_USER_NOT_FOUND: 'User not found', } as const; diff --git a/tests/utils/mocks/users.ts b/tests/utils/mocks/users.ts index 80758bd67..828103d38 100644 --- a/tests/utils/mocks/users.ts +++ b/tests/utils/mocks/users.ts @@ -6,7 +6,10 @@ * Swagger spec wrongly declares string enums for the latter). */ -import type { RawUserEntry } from '../../../src/models/identity/users.internal-types'; +import type { + RawUserEntry, + RawUserInviteResult, +} from '../../../src/models/identity/users.internal-types'; import type { RawUserGetResponse } from '../../../src/models/identity'; import { UserCategory, UserType } from '../../../src/models/identity'; import { USER_TEST_CONSTANTS } from '../constants/users'; @@ -62,3 +65,16 @@ export const createBasicRawUserEntry = ( legacyId: USER_TEST_CONSTANTS.LEGACY_ID, ...overrides, }); + +/** + * Builds a raw per-user invite result mirroring a live API response. + */ +export const createBasicRawUserInviteResult = ( + overrides?: Partial +): RawUserInviteResult => ({ + email: USER_TEST_CONSTANTS.EMAIL, + id: USER_TEST_CONSTANTS.USER_ID, + errorMsg: null, + success: true, + ...overrides, +});