From 5cac0f339d649cfe5857d33738210cbc1599370b Mon Sep 17 00:00:00 2001 From: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> Date: Mon, 12 Aug 2024 16:49:06 +0100 Subject: [PATCH] feat(Okta Node): Add Okta Node (#10278) Co-authored-by: Giulio Andreini Co-authored-by: Elias Meire --- packages/core/bin/generate-ui-types | 7 +- .../editor-ui/src/stores/credentials.store.ts | 4 +- .../credentials/OktaApi.credentials.ts | 3 +- packages/nodes-base/nodes/Okta/Okta.dark.svg | 3 + packages/nodes-base/nodes/Okta/Okta.node.ts | 56 ++ packages/nodes-base/nodes/Okta/Okta.svg | 3 + .../nodes-base/nodes/Okta/UserDescription.ts | 795 ++++++++++++++++++ .../nodes-base/nodes/Okta/UserFunctions.ts | 170 ++++ .../nodes/Okta/test/UserFunctions.test.ts | 375 +++++++++ packages/nodes-base/package.json | 1 + packages/workflow/src/Interfaces.ts | 1 + 11 files changed, 1414 insertions(+), 4 deletions(-) create mode 100644 packages/nodes-base/nodes/Okta/Okta.dark.svg create mode 100644 packages/nodes-base/nodes/Okta/Okta.node.ts create mode 100644 packages/nodes-base/nodes/Okta/Okta.svg create mode 100644 packages/nodes-base/nodes/Okta/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Okta/UserFunctions.ts create mode 100644 packages/nodes-base/nodes/Okta/test/UserFunctions.test.ts diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 988462d604..8cecb6b054 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -33,8 +33,11 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { const knownCredentials = loader.known.credentials; const credentialTypes = Object.values(loader.credentialTypes).map((data) => { const credentialType = data.type; - if (knownCredentials[credentialType.name].supportedNodes?.length > 0) { - delete credentialType.httpRequestNode; + if ( + knownCredentials[credentialType.name].supportedNodes?.length > 0 && + credentialType.httpRequestNode + ) { + credentialType.httpRequestNode.hidden = true; } return credentialType; }); diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index e5be582ea2..f2e209370a 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -206,7 +206,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { }); const httpOnlyCredentialTypes = computed(() => { - return allCredentialTypes.value.filter((credentialType) => credentialType.httpRequestNode); + return allCredentialTypes.value.filter( + (credentialType) => credentialType.httpRequestNode && !credentialType.httpRequestNode.hidden, + ); }); // #endregion diff --git a/packages/nodes-base/credentials/OktaApi.credentials.ts b/packages/nodes-base/credentials/OktaApi.credentials.ts index c5b925c071..2e690bfb77 100644 --- a/packages/nodes-base/credentials/OktaApi.credentials.ts +++ b/packages/nodes-base/credentials/OktaApi.credentials.ts @@ -30,12 +30,13 @@ export class OktaApi implements ICredentialType { placeholder: 'https://dev-123456.okta.com', }, { - displayName: 'SSWS Access Token', + displayName: 'Access Token', name: 'accessToken', type: 'string', typeOptions: { password: true }, required: true, default: '', + description: 'Secure Session Web Service Access Token', }, ]; diff --git a/packages/nodes-base/nodes/Okta/Okta.dark.svg b/packages/nodes-base/nodes/Okta/Okta.dark.svg new file mode 100644 index 0000000000..b6f1fd7e2b --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nodes-base/nodes/Okta/Okta.node.ts b/packages/nodes-base/nodes/Okta/Okta.node.ts new file mode 100644 index 0000000000..e9bd0185be --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.node.ts @@ -0,0 +1,56 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { userFields, userOperations } from './UserDescription'; +import { getUsers } from './UserFunctions'; + +export class Okta implements INodeType { + description: INodeTypeDescription = { + displayName: 'Okta', + name: 'okta', + icon: { light: 'file:Okta.svg', dark: 'file:Okta.dark.svg' }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Okta API', + defaults: { + name: 'Okta', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'oktaApi', + required: true, + }, + ], + requestDefaults: { + returnFullResponse: true, + baseURL: '={{$credentials.url.replace(new RegExp("/$"), "")}}', + headers: {}, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'User', + value: 'user', + }, + ], + default: 'user', + }, + + // USER + ...userOperations, + ...userFields, + ], + }; + + methods = { + listSearch: { + getUsers, + }, + }; +} diff --git a/packages/nodes-base/nodes/Okta/Okta.svg b/packages/nodes-base/nodes/Okta/Okta.svg new file mode 100644 index 0000000000..4ba579621f --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nodes-base/nodes/Okta/UserDescription.ts b/packages/nodes-base/nodes/Okta/UserDescription.ts new file mode 100644 index 0000000000..b10d580339 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/UserDescription.ts @@ -0,0 +1,795 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getCursorPaginator, simplifyGetAllResponse, simplifyGetResponse } from './UserFunctions'; +const BASE_API_URL = '/api/v1/users/'; +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + // Create Operation + { + name: 'Create', + value: 'create', + description: 'Create a new user', + routing: { + request: { + method: 'POST', + url: BASE_API_URL, + qs: { activate: '={{$parameter["activate"]}}' }, + returnFullResponse: true, + }, + }, + action: 'Create a new user', + }, + // Delete Operation + { + name: 'Delete', + value: 'delete', + description: 'Delete an existing user', + routing: { + request: { + method: 'DELETE', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + returnFullResponse: true, + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete a user', + }, + // Get Operation + { + name: 'Get', + value: 'get', + description: 'Get details of a user', + routing: { + request: { + method: 'GET', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + returnFullResponse: true, + qs: {}, + }, + output: { + postReceive: [simplifyGetResponse], + }, + }, + action: 'Get a user', + }, + // Get All Operation + { + name: 'Get Many', + value: 'getAll', + description: 'Get many users', + routing: { + request: { + method: 'GET', + url: BASE_API_URL, + qs: { search: '={{$parameter["searchQuery"]}}' }, + returnFullResponse: true, + }, + output: { + postReceive: [simplifyGetAllResponse], + }, + send: { + paginate: true, + }, + operations: { + pagination: getCursorPaginator(), + }, + }, + action: 'Get many users', + }, + // Update Operation + { + name: 'Update', + value: 'update', + description: 'Update an existing user', + routing: { + request: { + method: 'POST', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + returnFullResponse: true, + }, + }, + action: 'Update a user', + }, + ], + default: 'getAll', + }, +]; +const mainProfileFields: INodeProperties[] = [ + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + placeholder: 'e.g. Nathan', + default: '', + routing: { + send: { + property: 'profile.firstName', + type: 'body', + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + placeholder: 'e.g. Smith', + default: '', + routing: { + send: { + property: 'profile.lastName', + type: 'body', + }, + }, + }, + { + displayName: 'Username', + name: 'login', + type: 'string', + placeholder: 'e.g. nathan@example.com', + hint: 'Unique identifier for the user, must be an email', + default: '', + routing: { + send: { + property: 'profile.login', + type: 'body', + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'e.g. nathan@example.com', + default: '', + routing: { + send: { + property: 'profile.email', + type: 'body', + }, + }, + }, +]; +const createFields: INodeProperties[] = [ + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.city', + type: 'body', + }, + }, + }, + { + displayName: 'Cost Center', + name: 'costCenter', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.costCenter', + type: 'body', + }, + }, + }, + { + displayName: 'Country Code', + name: 'countryCode', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.countryCode', + type: 'body', + }, + }, + }, + { + displayName: 'Department', + name: 'department', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.department', + type: 'body', + }, + }, + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.displayName', + type: 'body', + }, + }, + }, + { + displayName: 'Division', + name: 'division', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.division', + type: 'body', + }, + }, + }, + { + displayName: 'Employee Number', + name: 'employeeNumber', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.employeeNumber', + type: 'body', + }, + }, + }, + { + displayName: 'Honorific Prefix', + name: 'honorificPrefix', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.honorificPrefix', + type: 'body', + }, + }, + }, + { + displayName: 'Honorific Suffix', + name: 'honorificSuffix', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.honorificSuffix', + type: 'body', + }, + }, + }, + { + displayName: 'Locale', + name: 'locale', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.locale', + type: 'body', + }, + }, + }, + { + displayName: 'Manager', + name: 'manager', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.manager', + type: 'body', + }, + }, + }, + { + displayName: 'ManagerId', + name: 'managerId', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.managerId', + type: 'body', + }, + }, + }, + { + displayName: 'Middle Name', + name: 'middleName', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.middleName', + type: 'body', + }, + }, + }, + { + displayName: 'Mobile Phone', + name: 'mobilePhone', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.mobilePhone', + type: 'body', + }, + }, + }, + { + displayName: 'Nick Name', + name: 'nickName', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.nickName', + type: 'body', + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { password: true }, + default: '', + routing: { + send: { + property: 'credentials.password.value', + type: 'body', + }, + }, + }, + { + displayName: 'Organization', + name: 'organization', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.organization', + type: 'body', + }, + }, + }, + { + displayName: 'Postal Address', + name: 'postalAddress', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.postalAddress', + type: 'body', + }, + }, + }, + { + displayName: 'Preferred Language', + name: 'preferredLanguage', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.preferredLanguage', + type: 'body', + }, + }, + }, + { + displayName: 'Primary Phone', + name: 'primaryPhone', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.primaryPhone', + type: 'body', + }, + }, + }, + { + displayName: 'Profile Url', + name: 'profileUrl', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.profileUrl', + type: 'body', + }, + }, + }, + { + displayName: 'Recovery Question Answer', + name: 'recoveryQuestionAnswer', + type: 'string', + default: '', + routing: { + send: { + property: 'credentials.recovery_question.answer', + type: 'body', + }, + }, + }, + { + displayName: 'Recovery Question Question', + name: 'recoveryQuestionQuestion', + type: 'string', + default: '', + routing: { + send: { + property: 'credentials.recovery_question.question', + type: 'body', + }, + }, + }, + { + displayName: 'Second Email', + name: 'secondEmail', + type: 'string', + typeOptions: { email: true }, + default: '', + routing: { + send: { + property: 'profile.secondEmail', + type: 'body', + }, + }, + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.state', + type: 'body', + }, + }, + }, + { + displayName: 'Street Address', + name: 'streetAddress', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.streetAddress', + type: 'body', + }, + }, + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.timezone', + type: 'body', + }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.title', + type: 'body', + }, + }, + }, + { + displayName: 'User Type', + name: 'userType', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.userType', + type: 'body', + }, + }, + }, + { + displayName: 'Zip Code', + name: 'zipCode', + type: 'string', + default: '', + routing: { + send: { + property: 'profile.zipCode', + type: 'body', + }, + }, + }, +]; +const updateFields: INodeProperties[] = createFields + .concat(mainProfileFields) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + +export const userFields: INodeProperties[] = [ + // Fields for 'get', 'update', and 'delete' operations + + { + displayName: 'User', + name: 'userId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'getUsers', + searchable: true, + }, + }, + { + displayName: 'By username', + name: 'login', + type: 'string', + placeholder: '', + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 00u1abcd2345EfGHIjk6', + }, + ], + displayOptions: { + show: { + resource: ['user'], + operation: ['get', 'update', 'delete'], + }, + }, + description: 'The user you want to operate on. Choose from the list, or specify an ID.', + }, + // Fields specific to 'create' operation + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + required: true, + placeholder: 'e.g. Nathan', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.firstName', + type: 'body', + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + required: true, + placeholder: 'e.g. Smith', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.lastName', + type: 'body', + }, + }, + }, + { + displayName: 'Username', + name: 'login', + type: 'string', + required: true, + placeholder: 'e.g. nathan@example.com', + hint: 'Unique identifier for the user, must be an email', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.login', + type: 'body', + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + placeholder: 'e.g. nathan@example.com', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.email', + type: 'body', + }, + }, + }, + { + displayName: 'Activate', + name: 'activate', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: true, + description: 'Whether to activate the user and allow access to all assigned applications', + }, + { + displayName: 'Fields', + name: 'getCreateFields', + type: 'collection', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: {}, + placeholder: 'Add field', + options: createFields, + }, + + // Fields for 'update' operations + { + displayName: 'Fields', + name: 'getUpdateFields', + type: 'collection', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + default: {}, + placeholder: 'Add field', + options: updateFields, + }, + + // Fields specific to 'getAll' operation + { + displayName: 'Search Query', + name: 'searchQuery', + type: 'string', + placeholder: 'e.g. profile.lastName sw "Smi"', + hint: 'Filter users by using the allowed syntax. More info.', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + default: '', + routing: { + request: { + qs: { + prefix: '={{$value}}', + }, + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 20, + routing: { + send: { + type: 'query', + property: 'limit', + }, + output: { + maxResults: '={{$value}}', // Set maxResults to the value of current parameter + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + // Fields for 'get' and 'getAll' operations + { + displayName: 'Simplify', + name: 'simplify', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['get', 'getAll'], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + // Fields specific to 'delete' operation + { + displayName: 'Send Email', + name: 'sendEmail', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['delete'], + }, + }, + default: false, + description: 'Whether to send a deactivation email to the administrator', + }, +]; diff --git a/packages/nodes-base/nodes/Okta/UserFunctions.ts b/packages/nodes-base/nodes/Okta/UserFunctions.ts new file mode 100644 index 0000000000..6def8ea296 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/UserFunctions.ts @@ -0,0 +1,170 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHookFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeListSearchResult, + INodePropertyOptions, +} from 'n8n-workflow'; + +type OktaUser = { + status: string; + created: string; + activated: string; + lastLogin: string; + lastUpdated: string; + passwordChanged: string; + profile: { + login: string; + email: string; + firstName: string; + lastName: string; + }; + id: string; +}; + +export async function oktaApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | IHookFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + url?: string, + option: IDataObject = {}, +): Promise { + const credentials = await this.getCredentials('oktaApi'); + const baseUrl = `${credentials.url as string}/api/v1/${resource}`; + const options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body: Object.keys(body).length ? body : undefined, + qs: Object.keys(qs).length ? qs : undefined, + url: url ?? baseUrl, + json: true, + ...option, + }; + return await (this.helpers.httpRequestWithAuthentication.call( + this, + 'oktaApi', + options, + ) as Promise); +} + +export async function getUsers( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const responseData: OktaUser[] = await oktaApiRequest.call(this, 'GET', '/users/'); + const filteredUsers = responseData.filter((user) => { + if (!filter) return true; + const username = `${user.profile.login}`.toLowerCase(); + return username.includes(filter.toLowerCase()); + }); + const users: INodePropertyOptions[] = filteredUsers.map((user) => ({ + name: `${user.profile.login}`, + value: user.id, + })); + return { + results: users, + }; +} + +function simplifyOktaUser(item: OktaUser): IDataObject { + return { + id: item.id, + status: item.status, + created: item.created, + activated: item.activated, + lastLogin: item.lastLogin, + lastUpdated: item.lastUpdated, + passwordChanged: item.passwordChanged, + profile: { + firstName: item.profile.firstName, + lastName: item.profile.lastName, + login: item.profile.login, + email: item.profile.email, + }, + }; +} + +export async function simplifyGetAllResponse( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _response: IN8nHttpFullResponse, +): Promise { + if (items.length === 0) return items; + const simplify = this.getNodeParameter('simplify'); + if (!simplify) + return ((items[0].json as unknown as IDataObject[]) ?? []).map((item: IDataObject) => ({ + json: item, + headers: _response.headers, + })) as INodeExecutionData[]; + let simplifiedItems: INodeExecutionData[] = []; + if (items[0].json) { + const jsonArray = items[0].json as unknown; + simplifiedItems = (jsonArray as OktaUser[]).map((item: OktaUser) => { + const simplifiedItem = simplifyOktaUser(item); + return { + json: simplifiedItem, + headers: _response.headers, + }; + }); + } + + return simplifiedItems; +} + +export async function simplifyGetResponse( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _response: IN8nHttpFullResponse, +): Promise { + const simplify = this.getNodeParameter('simplify'); + if (!simplify) return items; + const item = items[0].json as OktaUser; + const simplifiedItem = simplifyOktaUser(item); + + return [ + { + json: simplifiedItem, + }, + ] as INodeExecutionData[]; +} + +export const getCursorPaginator = () => { + return async function cursorPagination( + this: IExecutePaginationFunctions, + requestOptions: DeclarativeRestApiSettings.ResultOptions, + ): Promise { + if (!requestOptions.options.qs) { + requestOptions.options.qs = {}; + } + + let items: INodeExecutionData[] = []; + let responseData: INodeExecutionData[]; + let nextCursor: string | undefined = undefined; + const returnAll = this.getNodeParameter('returnAll', true) as boolean; + do { + requestOptions.options.qs.limit = 200; + requestOptions.options.qs.after = nextCursor; + responseData = await this.makeRoutingRequest(requestOptions); + if (responseData.length > 0) { + const headers = responseData[responseData.length - 1].headers; + const headersLink = (headers as IDataObject)?.link as string | undefined; + nextCursor = headersLink?.split('after=')[1]?.split('&')[0]?.split('>')[0]; + } + items = items.concat(responseData); + } while (returnAll && nextCursor); + + return items; + }; +}; diff --git a/packages/nodes-base/nodes/Okta/test/UserFunctions.test.ts b/packages/nodes-base/nodes/Okta/test/UserFunctions.test.ts new file mode 100644 index 0000000000..9e5262db67 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/test/UserFunctions.test.ts @@ -0,0 +1,375 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + INodeExecutionData, +} from 'n8n-workflow'; +import { + getCursorPaginator, + getUsers, + oktaApiRequest, + simplifyGetAllResponse, + simplifyGetResponse, +} from '../UserFunctions'; + +describe('oktaApiRequest', () => { + const mockGetCredentials = jest.fn(); + const mockHttpRequestWithAuthentication = jest.fn(); + + const mockContext = { + getCredentials: mockGetCredentials, + helpers: { + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, + }, + } as unknown as IExecuteFunctions; + + beforeEach(() => { + mockGetCredentials.mockClear(); + mockHttpRequestWithAuthentication.mockClear(); + }); + + it('should make a GET request and return data', async () => { + mockGetCredentials.mockResolvedValue({ url: 'https://okta.example.com' }); + mockHttpRequestWithAuthentication.mockResolvedValue([ + { profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }, + ]); + + const response = await oktaApiRequest.call(mockContext, 'GET', 'users'); + + expect(mockGetCredentials).toHaveBeenCalledWith('oktaApi'); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('oktaApi', { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'https://okta.example.com/api/v1/users', + json: true, + }); + expect(response).toEqual([{ profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }]); + }); + + // Tests for error handling + it('should handle errors from oktaApiRequest', async () => { + mockHttpRequestWithAuthentication.mockRejectedValue(new Error('Network error')); + + await expect(oktaApiRequest.call(mockContext, 'GET', 'users')).rejects.toThrow('Network error'); + }); +}); + +describe('getUsers', () => { + const mockOktaApiRequest = jest.fn(); + const mockContext = { + getCredentials: jest.fn().mockResolvedValue({ url: 'https://okta.example.com' }), + helpers: { + httpRequestWithAuthentication: mockOktaApiRequest, + }, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockOktaApiRequest.mockClear(); + }); + + it('should return users with filtering', async () => { + mockOktaApiRequest.mockResolvedValue([ + { profile: { login: 'John@example.com' }, id: '1' }, + { profile: { login: 'Jane@example.com' }, id: '2' }, + ]); + + const response = await getUsers.call(mockContext, 'john'); + + expect(response).toEqual({ + results: [{ name: 'John@example.com', value: '1' }], + }); + }); + + it('should return all users when no filter is applied', async () => { + mockOktaApiRequest.mockResolvedValue([ + { profile: { login: 'John@example.com' }, id: '1' }, + { profile: { login: 'Jane@example.com' }, id: '2' }, + ]); + + const response = await getUsers.call(mockContext); + + expect(response).toEqual({ + results: [ + { name: 'John@example.com', value: '1' }, + { name: 'Jane@example.com', value: '2' }, + ], + }); + }); + + // Tests for empty results + it('should handle empty results from oktaApiRequest', async () => { + mockOktaApiRequest.mockResolvedValue([]); + + const response = await getUsers.call(mockContext); + + expect(response).toEqual({ + results: [], + }); + }); +}); + +describe('simplifyGetAllResponse', () => { + const mockGetNodeParameter = jest.fn(); + const mockContext = { + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + const mockResponse = jest.fn() as unknown as IN8nHttpFullResponse; + + const items: INodeExecutionData[] = [ + { + json: [ + { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + some_item: 'some_value', + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + some_profile_item: 'some_profile_value', + }, + }, + ] as unknown as IDataObject, + }, + ]; + + beforeEach(() => { + mockGetNodeParameter.mockClear(); + }); + + it('should return items unchanged when simplify parameter is not set', async () => { + mockGetNodeParameter.mockReturnValueOnce(false); + + const expectedResult: INodeExecutionData[] = [ + { + json: { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + some_item: 'some_value', + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + some_profile_item: 'some_profile_value', + }, + }, + }, + ]; + + const result = await simplifyGetAllResponse.call(mockContext, items, mockResponse); + expect(result).toEqual(expectedResult); + }); + + it('should simplify items correctly', async () => { + mockGetNodeParameter.mockReturnValueOnce(true); + + const expectedResult: INodeExecutionData[] = [ + { + json: { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + }, + }, + }, + ]; + + const result = await simplifyGetAllResponse.call(mockContext, items, mockResponse); + expect(result).toEqual(expectedResult); + }); + + it('should return an empty array when items is an empty array', async () => { + mockGetNodeParameter.mockReturnValueOnce(false); + + const emptyArrayItems: INodeExecutionData[] = []; + + const result = await simplifyGetAllResponse.call(mockContext, emptyArrayItems, mockResponse); + expect(result).toEqual([]); + }); +}); + +describe('simplifyGetResponse', () => { + const mockGetNodeParameter = jest.fn(); + const mockContext = { + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + const mockResponse = jest.fn() as unknown as IN8nHttpFullResponse; + + const items: INodeExecutionData[] = [ + { + json: { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + some_item: 'some_value', + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + some_profile_item: 'some_profile_value', + }, + } as unknown as IDataObject, + }, + ]; + beforeEach(() => { + mockGetNodeParameter.mockClear(); + }); + + it('should return the item unchanged when simplify parameter is not set', async () => { + mockGetNodeParameter.mockReturnValueOnce(false); + + const expectedResult: INodeExecutionData[] = [ + { + json: { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + some_item: 'some_value', + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + some_profile_item: 'some_profile_value', + }, + }, + }, + ]; + + const result = await simplifyGetResponse.call(mockContext, items, mockResponse); + expect(result).toEqual(expectedResult); + }); + + it('should simplify the item correctly', async () => { + mockGetNodeParameter.mockReturnValueOnce(true); + + const expectedResult: INodeExecutionData[] = [ + { + json: { + id: '01', + status: 'ACTIVE', + created: '2023-01-01T00:00:00.000Z', + activated: '2023-01-01T00:00:01.000Z', + lastLogin: null, + lastUpdated: '2023-01-01T00:00:01.000Z', + passwordChanged: null, + profile: { + firstName: 'John', + lastName: 'Doe', + login: 'john.doe@example.com', + email: 'john.doe@example.com', + }, + }, + }, + ]; + + const result = await simplifyGetResponse.call(mockContext, items, mockResponse); + expect(result).toEqual(expectedResult); + }); +}); +describe('getCursorPaginator', () => { + let mockContext: IExecutePaginationFunctions; + let mockRequestOptions: DeclarativeRestApiSettings.ResultOptions; + const baseUrl = 'https://api.example.com'; + + beforeEach(() => { + mockContext = { + getNodeParameter: jest.fn(), + makeRoutingRequest: jest.fn(), + } as unknown as IExecutePaginationFunctions; + + mockRequestOptions = { + options: { + qs: {}, + }, + } as DeclarativeRestApiSettings.ResultOptions; + }); + + it('should return all items when returnAll is true', async () => { + const mockResponseData: INodeExecutionData[] = [ + { json: { id: 1 }, headers: { link: `<${baseUrl}?after=cursor1>` } }, + { json: { id: 2 }, headers: { link: `<${baseUrl}?after=cursor2>` } }, + { json: { id: 3 }, headers: { link: `<${baseUrl}>` } }, + ]; + + (mockContext.getNodeParameter as jest.Mock).mockReturnValue(true); + (mockContext.makeRoutingRequest as jest.Mock) + .mockResolvedValueOnce([mockResponseData[0]]) + .mockResolvedValueOnce([mockResponseData[1]]) + .mockResolvedValueOnce([mockResponseData[2]]); + + const paginator = getCursorPaginator().bind(mockContext); + const result = await paginator(mockRequestOptions); + + expect(result).toEqual(mockResponseData); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('returnAll', true); + expect(mockContext.makeRoutingRequest).toHaveBeenCalledTimes(3); + }); + + it('should return items until nextCursor is undefined', async () => { + const mockResponseData: INodeExecutionData[] = [ + { json: { id: 1 }, headers: { link: `<${baseUrl}?after=cursor1>` } }, + { json: { id: 2 }, headers: { link: `<${baseUrl}>` } }, + ]; + + (mockContext.getNodeParameter as jest.Mock).mockReturnValue(true); + (mockContext.makeRoutingRequest as jest.Mock) + .mockResolvedValueOnce([mockResponseData[0]]) + .mockResolvedValueOnce([mockResponseData[1]]); + + const paginator = getCursorPaginator().bind(mockContext); + const result = await paginator(mockRequestOptions); + + expect(result).toEqual(mockResponseData); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('returnAll', true); + expect(mockContext.makeRoutingRequest).toHaveBeenCalledTimes(2); + }); + + it('should handle empty response data', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValue(true); + (mockContext.makeRoutingRequest as jest.Mock).mockResolvedValue([]); + + const paginator = getCursorPaginator().bind(mockContext); + const result = await paginator(mockRequestOptions); + + expect(result).toEqual([]); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('returnAll', true); + expect(mockContext.makeRoutingRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 61f940d5cd..5c36310965 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -648,6 +648,7 @@ "dist/nodes/Notion/NotionTrigger.node.js", "dist/nodes/Npm/Npm.node.js", "dist/nodes/Odoo/Odoo.node.js", + "dist/nodes/Okta/Okta.node.js", "dist/nodes/OneSimpleApi/OneSimpleApi.node.js", "dist/nodes/OpenAi/OpenAi.node.js", "dist/nodes/OpenThesaurus/OpenThesaurus.node.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index c4607563db..eb6c3ca07c 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -308,6 +308,7 @@ export interface ICredentialTestRequestData { type ICredentialHttpRequestNode = { name: string; docsUrl: string; + hidden?: boolean; } & ({ apiBaseUrl: string } | { apiBaseUrlPlaceholder: string }); export interface ICredentialType {