diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 302d55dbf..473838727 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -9,6 +9,7 @@ This page lists the specific OAuth scopes required in external app for each SDK | `getAll()` | `OR.Assets` or `OR.Assets.Read` | | `getById()` | `OR.Assets` or `OR.Assets.Read` | | `getByName()` | `OR.Assets` or `OR.Assets.Read` | +| `updateValueById()` | `OR.Assets` or `OR.Assets.Write` | ## Jobs diff --git a/src/models/orchestrator/assets.models.ts b/src/models/orchestrator/assets.models.ts index c56623709..24096a534 100644 --- a/src/models/orchestrator/assets.models.ts +++ b/src/models/orchestrator/assets.models.ts @@ -1,4 +1,4 @@ -import { AssetGetAllOptions, AssetGetResponse, AssetGetByIdOptions, AssetGetByNameOptions } from './assets.types'; +import { AssetGetAllOptions, AssetGetResponse, AssetGetByIdOptions, AssetGetByNameOptions, AssetNewValue, AssetUpdateValueByIdOptions } from './assets.types'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination'; /** @@ -90,4 +90,37 @@ export interface AssetServiceModel { * ``` */ getByName(name: string, options?: AssetGetByNameOptions): Promise; + + /** + * Updates the value of an existing asset by ID. + * + * Fetches the asset internally to determine its type, then updates only the value while + * preserving the asset's name, scope, and description. + * + * **Supported value types:** `Text`, `Integer`, and `Bool` only. Other types + * (`Credential`, `Secret`) throw a `ValidationError`. + * + * The `newValue` runtime type must match the asset's `valueType`: + * - `Text` → `string` + * - `Integer` → `number` (integer) + * - `Bool` → `boolean` + * + * @param id - Asset ID + * @param newValue - New value to apply (string for `Text`, number for `Integer`, boolean for `Bool`) + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @returns Promise resolving when the asset has been updated + * + * @example + * ```typescript + * // Update a Text asset by folder ID + * await assets.updateValueById(, 'new-value', { folderId: }); + * + * // Update an Integer asset by folder key (GUID) + * await assets.updateValueById(, 42, { folderKey: '5f6dadf1-3677-49dc-8aca-c2999dd4b3ba' }); + * + * // Update a Bool asset by folder path + * await assets.updateValueById(, true, { folderPath: 'Shared/Finance' }); + * ``` + */ + updateValueById(id: number, newValue: AssetNewValue, options?: AssetUpdateValueByIdOptions): Promise; } diff --git a/src/models/orchestrator/assets.types.ts b/src/models/orchestrator/assets.types.ts index 44e712d5e..7e9e18ffa 100644 --- a/src/models/orchestrator/assets.types.ts +++ b/src/models/orchestrator/assets.types.ts @@ -13,14 +13,10 @@ export enum AssetValueScope { * Enum for Asset Value Type */ export enum AssetValueType { - DBConnectionString = 'DBConnectionString', - HttpConnectionString = 'HttpConnectionString', Text = 'Text', Bool = 'Bool', Integer = 'Integer', Credential = 'Credential', - WindowsCredential = 'WindowsCredential', - KeyValueList = 'KeyValueList', Secret = 'Secret' } @@ -73,3 +69,18 @@ export interface AssetGetByIdOptions extends BaseOptions {} * Options for getting a single asset by name */ export interface AssetGetByNameOptions extends FolderScopedOptions {} + +/** + * New value accepted by {@link AssetServiceModel.updateValueById}. + * + * The runtime type must match the asset's `valueType`: + * - `Text` → `string` + * - `Integer` → `number` + * - `Bool` → `boolean` + */ +export type AssetNewValue = string | number | boolean; + +/** + * Options for updating an asset value by ID + */ +export interface AssetUpdateValueByIdOptions extends FolderScopedOptions {} diff --git a/src/services/orchestrator/assets/assets.ts b/src/services/orchestrator/assets/assets.ts index 7ae05dc79..f99cdd100 100644 --- a/src/services/orchestrator/assets/assets.ts +++ b/src/services/orchestrator/assets/assets.ts @@ -1,9 +1,10 @@ import { FolderScopedService } from '../../folder-scoped'; -import { AssetGetResponse, AssetGetAllOptions, AssetGetByIdOptions, AssetGetByNameOptions } from '../../../models/orchestrator/assets.types'; +import { AssetGetResponse, AssetGetAllOptions, AssetGetByIdOptions, AssetGetByNameOptions, AssetNewValue, AssetUpdateValueByIdOptions, AssetValueScope, AssetValueType } from '../../../models/orchestrator/assets.types'; import { AssetServiceModel } from '../../../models/orchestrator/assets.models'; import { addPrefixToKeys, pascalToCamelCaseKeys, transformData } from '../../../utils/transform'; import { createHeaders } from '../../../utils/http/headers'; import { FOLDER_ID } from '../../../utils/constants/headers'; +import { resolveFolderHeaders } from '../../../utils/folder/folder-headers'; import { ASSET_ENDPOINTS } from '../../../utils/constants/endpoints'; import { ODATA_PREFIX, ODATA_OFFSET_PARAMS } from '../../../utils/constants/common'; import { AssetMap } from '../../../models/orchestrator/assets.constants'; @@ -12,6 +13,7 @@ import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '. import { PaginationHelpers } from '../../../utils/pagination/helpers'; import { PaginationType } from '../../../utils/pagination/internal-types'; import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; /** * Service for interacting with UiPath Orchestrator Assets API @@ -155,4 +157,123 @@ export class AssetService extends FolderScopedService implements AssetServiceMod (raw) => transformData(pascalToCamelCaseKeys(raw), AssetMap), ); } + + /** + * Updates the value of an existing asset by ID. + * + * Fetches the asset internally to determine its type, then updates only the value while + * preserving the asset's name, scope, and description. + * + * **Supported value types:** `Text`, `Integer`, and `Bool` only. Other types + * (`Credential`, `Secret`) throw a `ValidationError`. + * + * The `newValue` runtime type must match the asset's `valueType`: + * - `Text` → `string` + * - `Integer` → `number` (integer) + * - `Bool` → `boolean` + * + * @param id - Asset ID + * @param newValue - New value to apply (string for `Text`, number for `Integer`, boolean for `Bool`) + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @returns Promise resolving when the asset has been updated + * + * @example + * ```typescript + * import { Assets } from '@uipath/uipath-typescript/assets'; + * + * const assets = new Assets(sdk); + * + * // Update a Text asset by folder ID + * await assets.updateValueById(, 'new-value', { folderId: }); + * + * // Update an Integer asset by folder key (GUID) + * await assets.updateValueById(, 42, { folderKey: '5f6dadf1-3677-49dc-8aca-c2999dd4b3ba' }); + * + * // Update a Bool asset by folder path + * await assets.updateValueById(, true, { folderPath: 'Shared/Finance' }); + * ``` + */ + @track('Assets.UpdateValueById') + async updateValueById(id: number, newValue: AssetNewValue, options?: AssetUpdateValueByIdOptions): Promise { + if (!id) { + throw new ValidationError({ message: 'id is required for updateValueById' }); + } + if (newValue === null || newValue === undefined) { + throw new ValidationError({ message: 'newValue is required for updateValueById' }); + } + + const headers = resolveFolderHeaders({ + folderId: options?.folderId, + folderKey: options?.folderKey, + folderPath: options?.folderPath, + resourceType: 'Assets.updateValueById', + fallbackFolderKey: this.config.folderKey, + }); + + const existingResponse = await this.get<{ + Name: string; + ValueScope: AssetValueScope; + ValueType: AssetValueType; + Description: string | null; + }>( + ASSET_ENDPOINTS.GET_BY_ID(id), + { headers }, + ); + const existing = existingResponse.data; + + const valueField = resolveValueField(id, existing.ValueType, newValue); + + const body: Record = { + Id: id, + Name: existing.Name, + ValueScope: existing.ValueScope, + ValueType: existing.ValueType, + Description: existing.Description, + [valueField]: newValue, + }; + + await this.put( + ASSET_ENDPOINTS.GET_BY_ID(id), + body, + { headers }, + ); + } +} + +/** + * Maps the asset's `valueType` to the PUT body field carrying the new value, validating + * that the new value's runtime type matches the asset type. + */ +function resolveValueField( + id: number, + valueType: AssetValueType, + newValue: AssetNewValue, +): 'StringValue' | 'IntValue' | 'BoolValue' { + switch (valueType) { + case AssetValueType.Text: + if (typeof newValue !== 'string') { + throw new ValidationError({ + message: `Asset ${id} has valueType Text; newValue must be a string, got ${typeof newValue}`, + }); + } + return 'StringValue'; + case AssetValueType.Integer: + if (typeof newValue !== 'number' || !Number.isInteger(newValue)) { + throw new ValidationError({ + message: `Asset ${id} has valueType Integer; newValue must be an integer number, got ${typeof newValue}`, + }); + } + return 'IntValue'; + case AssetValueType.Bool: + if (typeof newValue !== 'boolean') { + throw new ValidationError({ + message: `Asset ${id} has valueType Bool; newValue must be a boolean, got ${typeof newValue}`, + }); + } + return 'BoolValue'; + default: + throw new ValidationError({ + message: `updateValueById only supports Text, Integer, or Bool assets; asset ${id} has valueType ${valueType}`, + }); + } } diff --git a/tests/integration/shared/action-center/tasks.integration.test.ts b/tests/integration/shared/action-center/tasks.integration.test.ts index a156a9a18..3302068e4 100644 --- a/tests/integration/shared/action-center/tasks.integration.test.ts +++ b/tests/integration/shared/action-center/tasks.integration.test.ts @@ -345,4 +345,4 @@ describe.each(modes)('Action Center Tasks - Integration Tests [%s]', (mode) => { await cleanupTestTask(createdTaskId); } }); -}); +}, 120000); diff --git a/tests/integration/shared/orchestrator/assets.integration.test.ts b/tests/integration/shared/orchestrator/assets.integration.test.ts index 907e1055d..dec8f0457 100644 --- a/tests/integration/shared/orchestrator/assets.integration.test.ts +++ b/tests/integration/shared/orchestrator/assets.integration.test.ts @@ -77,7 +77,7 @@ describe.each(modes)('Orchestrator Assets - Integration Tests [%s]', (mode) => { expect(result.name).toBeDefined(); expect(result.valueType).toBeDefined(); expect(typeof result.name).toBe('string'); - }); + }); }); describe('getByName', () => { @@ -159,6 +159,42 @@ describe.each(modes)('Orchestrator Assets - Integration Tests [%s]', (mode) => { }); }); + describe('updateValueById', () => { + it('should update an existing text asset and persist the change', async () => { + const { assets } = getServices(); + const config = getTestConfig(); + + if (!config.folderId) { + throw new Error('INTEGRATION_TEST_FOLDER_ID must be configured to test updateValueById'); + } + const folderId = Number(config.folderId); + + // Find an existing Text-type asset in the folder so we can update it safely + const allAssets = await assets.getAll({ + folderId, + pageSize: 20, + filter: "ValueType eq 'Text'", + }); + + if (allAssets.items.length === 0) { + throw new Error('No Text-type assets available in the configured folder to exercise updateValueById'); + } + + const target = allAssets.items[0]; + const previousValue = target.value ?? ''; + const newValue = `sdk-test-${Date.now()}`; + + const result = await assets.updateValueById(target.id, newValue, { folderId }); + expect(result).toBeUndefined(); + + const refreshed = await assets.getById(target.id, folderId); + expect(refreshed.value).toBe(newValue); + + // Restore the asset so the suite is idempotent + await assets.updateValueById(target.id, previousValue, { folderId }); + }); + }); + describe('Asset structure validation', () => { it('should have expected fields in asset objects', async () => { const { assets } = getServices(); diff --git a/tests/integration/utils/cleanup.ts b/tests/integration/utils/cleanup.ts index b3a1b15a3..df100c8f1 100644 --- a/tests/integration/utils/cleanup.ts +++ b/tests/integration/utils/cleanup.ts @@ -54,7 +54,7 @@ export async function cleanupTestTask( await retryWithBackoff(async () => { // Attempt to unassign if assigned try { - await tasks.unassign({ taskIds: [taskId] }); + await tasks.unassign([taskId]); } catch { // Ignore if already unassigned or can't be unassigned } diff --git a/tests/unit/services/orchestrator/assets.test.ts b/tests/unit/services/orchestrator/assets.test.ts index fe6f47383..cef224b08 100644 --- a/tests/unit/services/orchestrator/assets.test.ts +++ b/tests/unit/services/orchestrator/assets.test.ts @@ -76,7 +76,7 @@ describe('AssetService Unit Tests', () => { expect(result.id).toBe(ASSET_TEST_CONSTANTS.ASSET_ID); expect(result.name).toBe(ASSET_TEST_CONSTANTS.ASSET_NAME); expect(result.key).toBe(ASSET_TEST_CONSTANTS.ASSET_KEY); - expect(result.valueType).toBe(AssetValueType.DBConnectionString,); + expect(result.valueType).toBe(AssetValueType.Text,); expect(result.valueScope).toBe(AssetValueScope.Global,); // Verify the API call has correct endpoint and headers @@ -245,7 +245,7 @@ describe('AssetService Unit Tests', () => { expect(result.id).toBe(ASSET_TEST_CONSTANTS.ASSET_ID); expect(result.name).toBe(ASSET_TEST_CONSTANTS.ASSET_NAME); expect(result.key).toBe(ASSET_TEST_CONSTANTS.ASSET_KEY); - expect(result.valueType).toBe(AssetValueType.DBConnectionString); + expect(result.valueType).toBe(AssetValueType.Text); // Transform validation — camelCase fields present, PascalCase originals absent expect(result.createdTime).toBe(ASSET_TEST_CONSTANTS.CREATED_TIME); @@ -402,4 +402,197 @@ describe('AssetService Unit Tests', () => { expect(mockApiClient.get).not.toHaveBeenCalled(); }); }); + + describe('updateValueById', () => { + const mockExistingAsset = (overrides: Record = {}) => + createMockRawAsset({ + ValueType: AssetValueType.Text, + ValueScope: AssetValueScope.Global, + Value: 'old-value', + StringValue: 'old-value', + ...overrides, + }); + + it('should fetch the asset, then PUT with preserved name/scope/type and the new StringValue', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset()); + mockApiClient.put.mockResolvedValue({}); + + const result = await assetService.updateValueById( + ASSET_TEST_CONSTANTS.ASSET_ID, + 'new-text-value', + { folderId: TEST_CONSTANTS.FOLDER_ID }, + ); + + expect(result).toBeUndefined(); + + expect(mockApiClient.get).toHaveBeenCalledWith( + ASSET_ENDPOINTS.GET_BY_ID(ASSET_TEST_CONSTANTS.ASSET_ID), + expect.objectContaining({ + headers: expect.objectContaining({ + [FOLDER_ID]: TEST_CONSTANTS.FOLDER_ID.toString(), + }), + }), + ); + + expect(mockApiClient.put).toHaveBeenCalledWith( + ASSET_ENDPOINTS.GET_BY_ID(ASSET_TEST_CONSTANTS.ASSET_ID), + expect.objectContaining({ + Id: ASSET_TEST_CONSTANTS.ASSET_ID, + Name: ASSET_TEST_CONSTANTS.ASSET_NAME, + ValueScope: AssetValueScope.Global, + ValueType: AssetValueType.Text, + Description: ASSET_TEST_CONSTANTS.ASSET_DESCRIPTION, + StringValue: 'new-text-value', + }), + expect.objectContaining({ + headers: expect.objectContaining({ + [FOLDER_ID]: TEST_CONSTANTS.FOLDER_ID.toString(), + }), + }), + ); + }); + + it('should send IntValue when the existing asset is Integer', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Integer })); + mockApiClient.put.mockResolvedValue({}); + + await assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 42, { folderId: TEST_CONSTANTS.FOLDER_ID }); + + const [, body] = mockApiClient.put.mock.calls[0]; + expect(body.ValueType).toBe(AssetValueType.Integer); + expect(body.IntValue).toBe(42); + expect(body.StringValue).toBeUndefined(); + }); + + it('should send BoolValue when the existing asset is Bool (both true and false)', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Bool })); + mockApiClient.put.mockResolvedValue({}); + + await assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, true, { folderId: TEST_CONSTANTS.FOLDER_ID }); + + const [, trueBody] = mockApiClient.put.mock.calls[0]; + expect(trueBody.ValueType).toBe(AssetValueType.Bool); + expect(trueBody.BoolValue).toBe(true); + expect(trueBody.StringValue).toBeUndefined(); + + // `false` is falsy — a naive truthy guard (e.g., `if (!newValue)`) would silently + // reject it. Lock in that the falsy-but-valid case still produces a PUT. + await assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, false, { folderId: TEST_CONSTANTS.FOLDER_ID }); + + const [, falseBody] = mockApiClient.put.mock.calls[1]; + expect(falseBody.ValueType).toBe(AssetValueType.Bool); + expect(falseBody.BoolValue).toBe(false); + expect(falseBody.StringValue).toBeUndefined(); + }); + + it('should throw ValidationError when id is missing', async () => { + await expect( + assetService.updateValueById(0, 'x', { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + expect(mockApiClient.get).not.toHaveBeenCalled(); + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when no folder context is provided', async () => { + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'x'), + ).rejects.toBeInstanceOf(ValidationError); + expect(mockApiClient.get).not.toHaveBeenCalled(); + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should accept folderKey as folder context', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset()); + mockApiClient.put.mockResolvedValue({}); + + await assetService.updateValueById( + ASSET_TEST_CONSTANTS.ASSET_ID, + 'new-text-value', + { folderKey: ASSET_TEST_CONSTANTS.FOLDER_KEY }, + ); + + expect(mockApiClient.put).toHaveBeenCalledWith( + ASSET_ENDPOINTS.GET_BY_ID(ASSET_TEST_CONSTANTS.ASSET_ID), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-UIPATH-FolderKey': ASSET_TEST_CONSTANTS.FOLDER_KEY, + }), + }), + ); + }); + + it('should throw ValidationError when newValue is null or undefined', async () => { + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, null as unknown as string, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, undefined as unknown as string, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + expect(mockApiClient.get).not.toHaveBeenCalled(); + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when newValue type does not match Text asset', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Text })); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 42 as unknown as string, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when newValue type does not match Integer asset', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Integer })); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'not-a-number' as unknown as number, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + // Non-integer numbers should also fail + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 1.5, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when newValue type does not match Bool asset', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Bool })); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'true' as unknown as boolean, { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when the existing asset valueType is unsupported', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Credential })); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'x', { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + mockApiClient.get.mockResolvedValue(mockExistingAsset({ ValueType: AssetValueType.Secret })); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'x', { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toBeInstanceOf(ValidationError); + + expect(mockApiClient.put).not.toHaveBeenCalled(); + }); + + it('should propagate API errors from the PUT call', async () => { + mockApiClient.get.mockResolvedValue(mockExistingAsset()); + mockApiClient.put.mockRejectedValue(createMockError(ASSET_TEST_CONSTANTS.ERROR_ASSET_NOT_FOUND)); + + await expect( + assetService.updateValueById(ASSET_TEST_CONSTANTS.ASSET_ID, 'x', { folderId: TEST_CONSTANTS.FOLDER_ID }), + ).rejects.toThrow(ASSET_TEST_CONSTANTS.ERROR_ASSET_NOT_FOUND); + }); + }); }); diff --git a/tests/utils/mocks/assets.ts b/tests/utils/mocks/assets.ts index 350d032f1..c94416a40 100644 --- a/tests/utils/mocks/assets.ts +++ b/tests/utils/mocks/assets.ts @@ -21,7 +21,7 @@ export const createMockRawAsset = (overrides: Partial = {}): any => { Key: ASSET_TEST_CONSTANTS.ASSET_KEY, Description: ASSET_TEST_CONSTANTS.ASSET_DESCRIPTION, ValueScope: AssetValueScope.Global, - ValueType: AssetValueType.DBConnectionString, + ValueType: AssetValueType.Text, Value: ASSET_TEST_CONSTANTS.ASSET_VALUE, KeyValueList: [ { @@ -58,7 +58,7 @@ export const createBasicAsset = (overrides: Partial = {}): Ass key: ASSET_TEST_CONSTANTS.ASSET_KEY, description: ASSET_TEST_CONSTANTS.ASSET_DESCRIPTION, valueScope: AssetValueScope.Global, - valueType: AssetValueType.DBConnectionString, + valueType: AssetValueType.Text, value: ASSET_TEST_CONSTANTS.ASSET_VALUE, keyValueList: [ {