diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index cce03be7c..2775edf5a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -76,6 +76,7 @@ jobs: echo "traces_test_trace_id=${{ secrets.UIPATH_TRACES_TEST_TRACE_ID_DEV || secrets.UIPATH_TRACES_TEST_TRACE_ID }}" >> $GITHUB_OUTPUT 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 "identity_test_user_id=${{ secrets.UIPATH_IDENTITY_TEST_USER_ID_DEV || secrets.UIPATH_IDENTITY_TEST_USER_ID }}" >> $GITHUB_OUTPUT - name: Create Integration Test Configuration run: | @@ -105,6 +106,7 @@ jobs: TRACES_TEST_TRACE_ID=${{ steps.config.outputs.traces_test_trace_id }} TASKS_TEST_USER_GROUP_ID=${{ steps.config.outputs.tasks_test_user_group_id }} TASKS_TEST_USER_ID=${{ steps.config.outputs.tasks_test_user_id }} + IDENTITY_TEST_USER_ID=${{ steps.config.outputs.identity_test_user_id }} EOF - name: Run Integration Tests diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index dc63043d9..27c19095c 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -272,3 +272,11 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getSpansByReference()` | `Insights.RealTimeData Insights OR.Folders.Read` | | `getGovernanceDecisions()` | `Insights.RealTimeData Insights OR.Folders.Read` | | `getGovernanceSummary()` | `Insights.RealTimeData Insights OR.Folders.Read` | + +## Users + +User management is authorized by the caller's **organization role** (organization administrator), not by OAuth scopes — these endpoints enforce no scope requirement. Any token belonging to an org administrator can call them. + +| Method | OAuth Scope | +|--------|-------------| +| `getById()` | None — requires organization administrator role | diff --git a/mkdocs.yml b/mkdocs.yml index c2f2fd69e..9e5d428a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -208,6 +208,7 @@ nav: - Processes: api/interfaces/ProcessServiceModel.md - Queues: api/interfaces/QueueServiceModel.md - Tasks: api/interfaces/TaskServiceModel.md + - Users: api/interfaces/UserServiceModel.md - Traces: - api/interfaces/traces/index.md - Agent: api/interfaces/AgentTracesServiceModel.md diff --git a/package.json b/package.json index 0f49b1c63..4a5755c4a 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,16 @@ "types": "./dist/notifications/index.d.ts", "default": "./dist/notifications/index.cjs" } + }, + "./users": { + "import": { + "types": "./dist/users/index.d.ts", + "default": "./dist/users/index.mjs" + }, + "require": { + "types": "./dist/users/index.d.ts", + "default": "./dist/users/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 4904bb9d2..6804e9519 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -233,6 +233,11 @@ const serviceEntries = [ name: 'notifications', input: 'src/services/notification/index.ts', output: 'notifications/index' + }, + { + name: 'users', + input: 'src/services/identity/index.ts', + output: 'users/index' } ]; diff --git a/src/index.ts b/src/index.ts index adb5508d0..041d207c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export * from './models/conversational-agent'; export * from './models/agents'; export * as DuFramework from './models/document-understanding/framework'; export * from './models/governance'; +export * from './models/identity'; export * from './models/observability'; // Export error handling functionality (public API only) diff --git a/src/models/identity/index.ts b/src/models/identity/index.ts new file mode 100644 index 000000000..eea05126f --- /dev/null +++ b/src/models/identity/index.ts @@ -0,0 +1,6 @@ +/** + * Identity models barrel export. + */ + +export * from './users.types'; +export * from './users.models'; diff --git a/src/models/identity/users.constants.ts b/src/models/identity/users.constants.ts new file mode 100644 index 000000000..deb0e0aaf --- /dev/null +++ b/src/models/identity/users.constants.ts @@ -0,0 +1,34 @@ +import { UserType, UserCategory } from './users.types'; + +/** + * User field mappings (API field name → SDK field name). + * + * Semantic renames only — the API already returns camelCase, so no case + * conversion is involved. + */ +export const UserMap: { [key: string]: string } = { + creationTime: 'createdTime', + lastModificationTime: 'lastModifiedTime', + groupIDs: 'groupIds', +}; + +/** + * Maps numeric user type codes (from API) to {@link UserType} enum values. + */ +export const UserTypeMap: { [key: number]: UserType } = { + 0: UserType.User, + 1: UserType.Robot, + 2: UserType.DirectoryUser, + 3: UserType.DirectoryGroup, + 4: UserType.RobotAccount, + 5: UserType.Application, +}; + +/** + * Maps numeric user category codes (from API) to {@link UserCategory} enum values. + */ +export const UserCategoryMap: { [key: number]: UserCategory } = { + 0: UserCategory.Local, + 1: UserCategory.LinkedLocal, + 2: UserCategory.Directory, +}; diff --git a/src/models/identity/users.internal-types.ts b/src/models/identity/users.internal-types.ts new file mode 100644 index 000000000..cbec85f61 --- /dev/null +++ b/src/models/identity/users.internal-types.ts @@ -0,0 +1,48 @@ +/** + * Internal-only types for the Identity Users service. + * + * NOT exported from the public barrel (`src/models/identity/index.ts`). + */ + +/** + * Raw user shape as returned by the Identity user management API. + * + * Mirrors the API contract exactly: it uses the API's field names + * (`creationTime`, `groupIDs`, …) and numeric `type`/`category` codes — the + * Swagger spec declares string enums for these, but the live API returns + * numbers. The SDK renames/maps them before returning to consumers. + */ +export interface RawUserEntry { + id: string; + userName: string; + email: string; + emailConfirmed: boolean; + name: string | null; + surname: string | null; + displayName: string | null; + /** Renamed to `createdTime` in the SDK response. */ + creationTime: string; + /** Renamed to `lastModifiedTime` in the SDK response. */ + lastModificationTime: string | null; + lastLoginTime: string | null; + /** Renamed to `groupIds` in the SDK response. */ + groupIDs: string[]; + isActive: boolean; + bypassBasicAuthRestriction: boolean; + /** Numeric code mapped to the {@link UserType} enum in the SDK response. */ + type: number; + /** Numeric code mapped to the {@link UserCategory} enum in the SDK response. */ + category: number; + invitationAccepted: boolean; + // Internal field the SDK strips before returning to consumers: + /** Internal platform synchronization id — dropped from the SDK response. */ + legacyId?: number; +} + +/** + * Fields stripped from each {@link RawUserEntry} before it is returned to the + * SDK consumer. + */ +export const INTERNAL_USER_FIELDS = [ + 'legacyId', +] as const satisfies ReadonlyArray; diff --git a/src/models/identity/users.models.ts b/src/models/identity/users.models.ts new file mode 100644 index 000000000..73b1482c3 --- /dev/null +++ b/src/models/identity/users.models.ts @@ -0,0 +1,46 @@ +/** + * Identity Users service model — public response shapes and the ServiceModel + * interface that drives generated API documentation. + */ + +import type { RawUserGetResponse } from './users.types'; + +/** + * User returned by the Users service. + */ +export interface UserGetResponse extends RawUserGetResponse {} + +/** + * Service model for managing users in a UiPath organization. + * + * Provides organization-level user administration. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Users } from '@uipath/uipath-typescript/users'; + * + * const users = new Users(sdk); + * const user = await users.getById(''); + * ``` + */ +export interface UserServiceModel { + /** + * Gets a user by ID. + * + * Returns the full user details including profile fields, group membership, + * activity flags and invitation state. + * + * @param userId - User GUID + * @returns Promise resolving to a {@link UserGetResponse} with the user's profile fields, group membership, activity flags and invitation state + * + * @example + * ```typescript + * const user = await users.getById(''); + * console.log(`${user.displayName} (${user.email}) — active: ${user.isActive}`); + * ``` + */ + getById(userId: string): Promise; +} diff --git a/src/models/identity/users.types.ts b/src/models/identity/users.types.ts new file mode 100644 index 000000000..501f9c555 --- /dev/null +++ b/src/models/identity/users.types.ts @@ -0,0 +1,74 @@ +/** + * Identity user management types — response shapes and enums. + */ + +/** + * Defines how a user was created and how it is supposed to be used. + */ +export enum UserType { + /** Standard interactive user. */ + User = 'user', + /** Robot user driving unattended automation. */ + Robot = 'robot', + /** User provisioned from an external directory (e.g. Azure AD). */ + DirectoryUser = 'directoryUser', + /** Group provisioned from an external directory. */ + DirectoryGroup = 'directoryGroup', + /** Robot account (non-interactive machine identity). */ + RobotAccount = 'robotAccount', + /** External application identity. */ + Application = 'application', +} + +/** + * Discriminates a user by how they relate to directory provisioning. + */ +export enum UserCategory { + /** Local account managed in UiPath. */ + Local = 'local', + /** Local account linked to a directory identity. */ + LinkedLocal = 'linkedLocal', + /** Account provisioned from an external directory. */ + Directory = 'directory', +} + +/** + * User as returned by the Identity user management API. + * + * Field selection: `legacyId` (an internal platform synchronization field) is returned + * by the API but dropped from the SDK because it has no use for an application developer. + */ +export interface RawUserGetResponse { + /** User GUID. */ + id: string; + /** The username. */ + userName: string; + /** Email address. Empty string when the user has no email. */ + email: string; + /** Whether the email address has been confirmed. */ + emailConfirmed: boolean; + /** First name. */ + name: string | null; + /** Last name. */ + surname: string | null; + /** Display name. */ + displayName: string | null; + /** When the user was created. */ + createdTime: string; + /** When the user was last modified. */ + lastModifiedTime: string | null; + /** When the user last logged in. `null` if the user has never logged in. */ + lastLoginTime: string | null; + /** GUIDs of the groups the user is a member of. */ + groupIds: string[]; + /** Whether the user is active. */ + isActive: boolean; + /** Whether this user bypasses the basic authentication restriction. */ + bypassBasicAuthRestriction: boolean; + /** How the user was created and is supposed to be used. */ + type: UserType; + /** How the user relates to directory provisioning. */ + category: UserCategory; + /** Whether the user has accepted their invitation. */ + invitationAccepted: boolean; +} diff --git a/src/services/identity/index.ts b/src/services/identity/index.ts new file mode 100644 index 000000000..c6e962b01 --- /dev/null +++ b/src/services/identity/index.ts @@ -0,0 +1,25 @@ +/** + * Identity Users Service Module + * + * Provides organization-level user administration via the UiPath Identity API: + * - `Users` — retrieve users in the organization + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Users } from '@uipath/uipath-typescript/users'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const users = new Users(sdk); + * const user = await users.getById(''); + * ``` + * + * @module + */ + +export { UserService as Users } from './users'; + +// Models (types, enums, response shapes) +export * from '../../models/identity'; diff --git a/src/services/identity/users.ts b/src/services/identity/users.ts new file mode 100644 index 000000000..e932c5a7d --- /dev/null +++ b/src/services/identity/users.ts @@ -0,0 +1,49 @@ +/** + * UserService — organization-level user administration via the Identity API. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { RawUserGetResponse } from '../../models/identity/users.types'; +import type { UserGetResponse, UserServiceModel } from '../../models/identity/users.models'; +import { + INTERNAL_USER_FIELDS, + type RawUserEntry, +} from '../../models/identity/users.internal-types'; +import { UserCategoryMap, UserMap, UserTypeMap } from '../../models/identity/users.constants'; + +import { IDENTITY_USER_ENDPOINTS } from '../../utils/constants/endpoints'; +import { applyDataTransforms, transformData } from '../../utils/transform'; + +/** + * Service for managing users in a UiPath organization. + * + * All operations route at the **organization** level — no tenant or folder + * context is involved. + */ +export class UserService extends BaseService implements UserServiceModel { + @track('Users.GetById') + async getById(userId: string): Promise { + const response = await this.get(IDENTITY_USER_ENDPOINTS.BY_ID(userId)); + return this.transformUser(response.data); + } + + /** + * 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. + */ + private transformUser(user: RawUserEntry): RawUserGetResponse { + const stripped: RawUserEntry = { ...user }; + for (const field of INTERNAL_USER_FIELDS) { + delete stripped[field]; + } + const renamed = transformData(stripped, UserMap) as unknown as RawUserGetResponse; + return applyDataTransforms(renamed, { + field: 'type', + valueMap: UserTypeMap, + transform: (data) => + applyDataTransforms(data, { field: 'category', valueMap: UserCategoryMap }), + }); + } +} diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index cef473f74..4416d04c4 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -3,6 +3,17 @@ */ export const ORCHESTRATOR_BASE = 'orchestrator_'; +/** + * Identity server base for **organization**-level API routes (e.g. user management). + * Identity URLs do not include a tenant segment, so — like {@link NOTIFICATION_BASE} — + * the `../` prefix relies on `URL` path normalization to collapse the tenant segment + * that {@link ApiClient} unconditionally inserts (`{orgName}/{tenantName}/{path}`): + * `{orgName}/{tenantName}/../identity_/...` resolves to `{orgName}/identity_/...`. + * + * Do NOT remove the leading `../`. OAuth/token endpoints use {@link IDENTITY_BASE} + * instead because they are resolved outside {@link ApiClient}. + */ +export const IDENTITY_ORG_BASE = '../identity_'; export const PIMS_BASE = 'pims_'; export const DATAFABRIC_BASE = 'datafabric_'; export const IDENTITY_BASE = 'identity_'; diff --git a/src/utils/constants/endpoints/identity.ts b/src/utils/constants/endpoints/identity.ts index 09d490097..99bfc78fc 100644 --- a/src/utils/constants/endpoints/identity.ts +++ b/src/utils/constants/endpoints/identity.ts @@ -2,7 +2,7 @@ * Identity/Authentication Endpoints */ -import { IDENTITY_BASE } from './base'; +import { IDENTITY_BASE, IDENTITY_ORG_BASE } from './base'; /** * Identity Service Endpoints @@ -12,3 +12,14 @@ export const IDENTITY_ENDPOINTS = { TOKEN: `${IDENTITY_BASE}/connect/token`, AUTHORIZE: `${IDENTITY_BASE}/connect/authorize`, } as const; + +const USER_API_BASE = `${IDENTITY_ORG_BASE}/api/User`; + +/** + * User management endpoints. + * + * URLs route at the **organization** level (no tenant segment); see {@link IDENTITY_ORG_BASE}. + */ +export const IDENTITY_USER_ENDPOINTS = { + BY_ID: (userId: string) => `${USER_API_BASE}/${userId}`, +} as const; diff --git a/tests/.env.integration.example b/tests/.env.integration.example index dac7784a8..1c78a80fe 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -84,3 +84,7 @@ 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= + +# GUID of a pre-existing user in the test organization (UiPath Cloud > Admin > +# Accounts & Groups). Required by the Identity Users getById tests. +IDENTITY_TEST_USER_ID= diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index d53d7a405..ee211b219 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -9,6 +9,11 @@ export interface IntegrationConfig { orgName: string; tenantName: string; tenantId?: string; + /** + * GUID of a pre-existing user in the test organization — required by the + * Identity Users `getById` tests. + */ + identityTestUserId?: string; secret: string; timeout: number; skipCleanup: boolean; @@ -79,6 +84,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { orgName: rawConfig.orgName as string, tenantName: rawConfig.tenantName as string, tenantId: typeof rawConfig.tenantId === 'string' ? rawConfig.tenantId : undefined, + identityTestUserId: typeof rawConfig.identityTestUserId === 'string' ? rawConfig.identityTestUserId : 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, @@ -122,6 +128,7 @@ export function loadIntegrationConfig(): IntegrationConfig { orgName: process.env.UIPATH_ORG_NAME, tenantName: process.env.UIPATH_TENANT_NAME, tenantId: process.env.UIPATH_TENANT_ID_DEV || undefined, + identityTestUserId: process.env.IDENTITY_TEST_USER_ID || undefined, secret: process.env.UIPATH_SECRET, timeout: process.env.INTEGRATION_TEST_TIMEOUT ? parseInt(process.env.INTEGRATION_TEST_TIMEOUT, 10) diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 211486f56..e51a1bd46 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -22,6 +22,7 @@ import { AgentTraces } from '../../../src/services/observability/traces/agent'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; import { Notifications, Subscriptions } from '../../../src/services/notification'; +import { Users } from '../../../src/services/identity'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -66,6 +67,7 @@ export interface TestServices { governance?: Governance; notifications?: Notifications; subscriptions?: Subscriptions; + users?: Users; } /** @@ -157,6 +159,7 @@ function createV1Services(config: IntegrationConfig): TestServices { governance: new Governance(sdk), notifications: new Notifications(sdk), subscriptions: new Subscriptions(sdk), + users: new Users(sdk), }; } diff --git a/tests/integration/shared/identity/users.integration.test.ts b/tests/integration/shared/identity/users.integration.test.ts new file mode 100644 index 000000000..caa54ddfe --- /dev/null +++ b/tests/integration/shared/identity/users.integration.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { Users } from '../../../../src/services/identity'; +import { UserCategory, UserType } from '../../../../src/models/identity'; + +// New modular service — v1 init only. +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let users!: Users; + /** GUID of a pre-existing user in the test organization. */ + let testUserId!: string; + + beforeAll(() => { + const service = getServices().users; + if (!service) { + throw new Error('Users service is not registered for this init mode'); + } + users = service; + + const configuredUserId = getTestConfig().identityTestUserId; + if (!configuredUserId) { + throw new Error( + 'IDENTITY_TEST_USER_ID is not configured. Set it to the GUID of an existing ' + + 'user in the test organization so the user can be retrieved.', + ); + } + testUserId = configuredUserId; + }); + + describe('getById', () => { + it('should retrieve the user by ID', async () => { + const user = await users.getById(testUserId); + + expect(user.id).toBe(testUserId); + expect(typeof user.userName).toBe('string'); + expect(user.userName.length).toBeGreaterThan(0); + expect(typeof user.isActive).toBe('boolean'); + expect(typeof user.invitationAccepted).toBe('boolean'); + }); + + it('should apply the SDK transform pipeline to the live response', async () => { + const user = await users.getById(testUserId); + + // (a) Renamed/typed fields present with values. + expect(typeof user.createdTime).toBe('string'); + expect(user.createdTime.length).toBeGreaterThan(0); + expect(Array.isArray(user.groupIds)).toBe(true); + expect(Object.values(UserType)).toContain(user.type); + expect(Object.values(UserCategory)).toContain(user.category); + + // (b) Original API fields are absent. + expect(user).not.toHaveProperty('creationTime'); + expect(user).not.toHaveProperty('lastModificationTime'); + expect(user).not.toHaveProperty('groupIDs'); + + // (c) Internal fields are stripped. + expect(user).not.toHaveProperty('legacyId'); + }); + }); +}); diff --git a/tests/unit/services/identity/users.test.ts b/tests/unit/services/identity/users.test.ts new file mode 100644 index 000000000..dd1f9f0e8 --- /dev/null +++ b/tests/unit/services/identity/users.test.ts @@ -0,0 +1,81 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { UserService } from '../../../../src/services/identity/users'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { + createBasicRawUserEntry, + USER_TEST_CONSTANTS, + createMockError, +} from '../../../utils/mocks'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { IDENTITY_USER_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { UserCategory, UserType } from '../../../../src/models/identity'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +// ===== TEST SUITE ===== +describe('UserService Unit Tests', () => { + let userService: UserService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(function () { return mockApiClient as unknown as ApiClient; }); + + userService = new UserService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getById', () => { + it('should get a user by ID', async () => { + mockApiClient.get.mockResolvedValue(createBasicRawUserEntry()); + + const result = await userService.getById(USER_TEST_CONSTANTS.USER_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.BY_ID(USER_TEST_CONSTANTS.USER_ID), + expect.anything() + ); + expect(result.id).toBe(USER_TEST_CONSTANTS.USER_ID); + expect(result.userName).toBe(USER_TEST_CONSTANTS.USER_NAME); + expect(result.email).toBe(USER_TEST_CONSTANTS.EMAIL); + expect(result.isActive).toBe(true); + }); + + it('should apply field renames, enum mappings and drop internal fields', async () => { + mockApiClient.get.mockResolvedValue(createBasicRawUserEntry()); + + const result = await userService.getById(USER_TEST_CONSTANTS.USER_ID); + + // Renamed fields carry the original values + expect(result.createdTime).toBe(USER_TEST_CONSTANTS.CREATION_TIME); + expect(result.lastModifiedTime).toBe(USER_TEST_CONSTANTS.LAST_MODIFICATION_TIME); + expect(result.groupIds).toEqual([USER_TEST_CONSTANTS.GROUP_ID]); + + // Numeric codes are mapped to enums + expect(result.type).toBe(UserType.User); + expect(result.category).toBe(UserCategory.Local); + + // Original API fields are absent + expect((result as any).creationTime).toBeUndefined(); + expect((result as any).lastModificationTime).toBeUndefined(); + expect((result as any).groupIDs).toBeUndefined(); + + // Internal fields are dropped + expect((result as any).legacyId).toBeUndefined(); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); + + await expect(userService.getById(USER_TEST_CONSTANTS.USER_ID)).rejects.toThrow( + USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND + ); + }); + }); +}); diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index b0f038b53..d1384edf3 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -20,3 +20,4 @@ export * from './traces'; export * from './agents'; export * from './governance'; export * from './notification'; +export * from './users'; diff --git a/tests/utils/constants/users.ts b/tests/utils/constants/users.ts new file mode 100644 index 000000000..ffd52b325 --- /dev/null +++ b/tests/utils/constants/users.ts @@ -0,0 +1,29 @@ +/** + * Identity Users test constants. + */ + +export const USER_TEST_CONSTANTS = { + // User identifiers + USER_ID: '1fb982fc-6078-408e-98ef-180739a17cc5', + + // Group GUIDs + GROUP_ID: '35551807-06b1-4cda-90a1-2fb84851eee7', + + // Profile fields + USER_NAME: 'jdoe', + EMAIL: 'jdoe@acme.com', + FIRST_NAME: 'Jane', + LAST_NAME: 'Doe', + DISPLAY_NAME: 'Jane Doe', + + // Timestamps (distinctive values so rename tests verify value preservation) + CREATION_TIME: '2025-08-12T06:41:10.0901663', + LAST_MODIFICATION_TIME: '2025-08-12T06:41:54.1519378', + LAST_LOGIN_TIME: '2026-07-07T08:10:28.66354', + + // Internal API field dropped by the SDK + LEGACY_ID: 1141202, + + // Error messages + ERROR_USER_NOT_FOUND: 'User not found', +} as const; diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index 9cdfa44fb..a81f5a44b 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -23,6 +23,8 @@ export * from './memory'; export * from './traces'; export * from './governance'; export * from './notification'; +export * from './users'; // Re-export constants for convenience -export * from '../constants'; \ No newline at end of file +export * from '../constants'; + diff --git a/tests/utils/mocks/users.ts b/tests/utils/mocks/users.ts new file mode 100644 index 000000000..114bbb1c5 --- /dev/null +++ b/tests/utils/mocks/users.ts @@ -0,0 +1,37 @@ +/** + * Identity Users mock factories. + * + * Shapes mirror the real API responses captured live during onboarding — the API + * returns camelCase keys, `groupIDs`, and numeric `type`/`category` codes (the + * Swagger spec wrongly declares string enums for the latter). + */ + +import type { RawUserEntry } from '../../../src/models/identity/users.internal-types'; +import { USER_TEST_CONSTANTS } from '../constants/users'; + +/** + * Builds a raw user entry mirroring a live API response. + */ +export const createBasicRawUserEntry = ( + overrides?: Partial +): RawUserEntry => ({ + id: USER_TEST_CONSTANTS.USER_ID, + userName: USER_TEST_CONSTANTS.USER_NAME, + email: USER_TEST_CONSTANTS.EMAIL, + emailConfirmed: true, + name: USER_TEST_CONSTANTS.FIRST_NAME, + surname: USER_TEST_CONSTANTS.LAST_NAME, + displayName: USER_TEST_CONSTANTS.DISPLAY_NAME, + creationTime: USER_TEST_CONSTANTS.CREATION_TIME, + lastModificationTime: USER_TEST_CONSTANTS.LAST_MODIFICATION_TIME, + lastLoginTime: USER_TEST_CONSTANTS.LAST_LOGIN_TIME, + groupIDs: [USER_TEST_CONSTANTS.GROUP_ID], + isActive: true, + bypassBasicAuthRestriction: false, + type: 0, + category: 0, + invitationAccepted: true, + // Internal field the API includes but the SDK drops: + legacyId: USER_TEST_CONSTANTS.LEGACY_ID, + ...overrides, +});