From da910f5eafc5960bc0637724271b9cb12da57337 Mon Sep 17 00:00:00 2001 From: Vikas Rathee Date: Tue, 15 Sep 2026 09:13:13 +0000 Subject: [PATCH] feat: Add Microsoft Dataverse core node (#34286) Co-authored-by: Stephen Wright --- ...MicrosoftDataverseOAuth2Api.credentials.ts | 142 ++++ ...softDataverseOAuth2Api.credentials.test.ts | 157 ++++ .../Microsoft/Dataverse/GenericFunctions.ts | 462 ++++++++++++ .../Dataverse/MicrosoftDataverse.node.json | 18 + .../Dataverse/MicrosoftDataverse.node.ts | 121 ++++ .../nodes/Microsoft/Dataverse/constants.ts | 28 + .../nodes/Microsoft/Dataverse/loadOptions.ts | 414 +++++++++++ .../Dataverse/microsoftDataverse.svg | 60 ++ .../Dataverse/operations/createRow.ts | 60 ++ .../Dataverse/operations/deleteRow.ts | 43 ++ .../Dataverse/operations/getManyRows.ts | 172 +++++ .../Microsoft/Dataverse/operations/getRow.ts | 54 ++ .../Microsoft/Dataverse/operations/index.ts | 36 + .../Microsoft/Dataverse/operations/lookups.ts | 446 ++++++++++++ .../Dataverse/operations/metadata.ts | 115 +++ .../Microsoft/Dataverse/operations/shared.ts | 411 +++++++++++ .../Dataverse/operations/sharedProperties.ts | 254 +++++++ .../Microsoft/Dataverse/operations/types.ts | 42 ++ .../Dataverse/operations/updateRow.ts | 77 ++ .../Dataverse/operations/upsertRow.ts | 175 +++++ .../Dataverse/test/GenericFunctions.test.ts | 567 +++++++++++++++ .../test/MicrosoftDataverse.node.test.ts | 217 ++++++ .../Dataverse/test/loadOptions.test.ts | 414 +++++++++++ .../Microsoft/Dataverse/test/lookups.test.ts | 513 +++++++++++++ .../Microsoft/Dataverse/test/metadata.test.ts | 38 + .../Dataverse/test/operations.test.ts | 679 ++++++++++++++++++ .../Microsoft/Dataverse/test/shared.test.ts | 426 +++++++++++ packages/nodes-base/package.json | 2 + 28 files changed, 6143 insertions(+) create mode 100644 packages/nodes-base/credentials/MicrosoftDataverseOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/test/MicrosoftDataverseOAuth2Api.credentials.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.json create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/microsoftDataverse.svg create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/createRow.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/deleteRow.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/getManyRows.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/getRow.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/index.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/lookups.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/metadata.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/shared.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/sharedProperties.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/types.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/updateRow.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/operations/upsertRow.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/GenericFunctions.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/MicrosoftDataverse.node.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/loadOptions.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/lookups.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/metadata.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/operations.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Dataverse/test/shared.test.ts diff --git a/packages/nodes-base/credentials/MicrosoftDataverseOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MicrosoftDataverseOAuth2Api.credentials.ts new file mode 100644 index 00000000000..2741a8fd6a8 --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftDataverseOAuth2Api.credentials.ts @@ -0,0 +1,142 @@ +import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MicrosoftDataverseOAuth2Api implements ICredentialType { + name = 'microsoftDataverseOAuth2Api'; + + extends = ['microsoftOAuth2Api']; + + displayName = 'Microsoft Dataverse OAuth2 API'; + + documentationUrl = 'microsoftdataverse'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'options', + options: [ + { + name: 'Authorization Code', + value: 'authorizationCode', + description: 'Delegated user access via interactive sign-in (recommended)', + }, + { + name: 'Client Credentials', + value: 'clientCredentials', + description: 'App-only access using a service principal (no signed-in user)', + }, + ], + default: 'authorizationCode', + description: + 'OAuth2 flow to use. ' + + '"Authorization Code" signs in as a real user and uses refresh tokens for long-lived workflows (recommended). ' + + '"Client Credentials" acts as the application itself — no signed-in user, access token re-acquired silently on expiry.', + }, + { + displayName: 'Tenant ID', + name: 'tenantId', + type: 'string', + default: 'common', + required: true, + description: + 'Microsoft Entra tenant ID (GUID) or verified domain (e.g. contoso.onmicrosoft.com). ' + + 'Find it at portal.azure.com → Microsoft Entra ID → Overview.', + hint: + 'Authorization Code: "common" works for multi-tenant apps; use a specific GUID or domain for single-tenant. ' + + 'Client Credentials: must be a specific tenant GUID — "common" is not accepted by Entra.', + }, + { + displayName: 'Environment URL', + name: 'environmentUrl', + type: 'string', + placeholder: 'https://yourorg.crm.dynamics.com', + default: '', + required: true, + validateType: 'url', + description: + 'Base URL of your Dataverse environment. ' + + 'Find it in Power Platform admin center under your environment details, ' + + 'or inside the environment at Settings → Session details.', + hint: 'Must start with https://. A trailing slash is accepted and normalized. Example: https://yourorg.crm.dynamics.com', + }, + { + displayName: 'National Cloud', + name: 'cloud', + type: 'options', + default: 'global', + description: + 'The Microsoft national cloud your Dataverse environment lives in. Controls the Entra login host used for authentication.', + options: [ + { name: 'Global (Public Cloud)', value: 'global' }, + { name: 'US Government (GCC High)', value: 'usgov' }, + { name: 'US Government (DoD)', value: 'dod' }, + { name: 'China (21Vianet)', value: 'china' }, + ], + }, + { + // Login host is derived from the selected national cloud so a sovereign + // tenant authenticates against the matching login host. Values are a fixed + // Microsoft host enum (mirrors LOGIN_HOSTS_BY_GRAPH_URL in the Entra + // credential), keeping the token exchange on trusted hosts. + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: + '={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/authorize" }}', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: + '={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/token" }}', + }, + { + // Scope resolution rules: + // 1. `/.default` — always included; bundles the Dataverse API permissions + // granted to the app in Entra (avoids enumerating individual scopes). + // 2. `offline_access` — appended for Authorization Code only; tells Entra to return a + // refresh token so n8n's oAuth2Api base can silently reissue access tokens on expiry. + // 3. Omitted for Client Credentials — Entra rejects `offline_access` for app-only flows + // with AADSTS70011 ("The provided value for the input parameter 'scope' is not valid"). + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: + '={{ $self["environmentUrl"].trim().replace(/\\/+$/, "") + "/.default" + ($self["grantType"] === "clientCredentials" ? "" : " offline_access") }}', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + { + // Inherited from microsoftOAuth2Api but unused: Dataverse doesn't call + // Microsoft Graph (the data URL comes from environmentUrl) and the login + // host is driven by the National Cloud selector above. Hide it so the modal + // doesn't render a control that does nothing. + displayName: 'Microsoft Graph API Base URL', + name: 'graphApiBaseUrl', + type: 'hidden', + default: 'https://graph.microsoft.com', + }, + ]; + + // Runs on save so setup problems surface immediately instead of as a 403 at + // execution time. Crucially this validates the Client Credentials flow, which + // has no interactive connect step: Entra issues an app-only token even when the + // application user hasn't been created in the environment, so a bare token + // acquisition proves nothing. GET WhoAmI additionally catches a wrong tenant + // and a mistyped environment URL. The trailing slash is stripped the same way + // as the scope expression and resolveBaseUrl; the API version is kept in sync + // with DATAVERSE_API_PATH in the node's constants.ts. + test: ICredentialTestRequest = { + request: { + baseURL: '={{ $credentials.environmentUrl.trim().replace(/\\/+$/, "") }}', + url: '/api/data/v9.2/WhoAmI', + method: 'GET', + headers: { Accept: 'application/json' }, + }, + }; +} diff --git a/packages/nodes-base/credentials/test/MicrosoftDataverseOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/MicrosoftDataverseOAuth2Api.credentials.test.ts new file mode 100644 index 00000000000..c9ecb8e855a --- /dev/null +++ b/packages/nodes-base/credentials/test/MicrosoftDataverseOAuth2Api.credentials.test.ts @@ -0,0 +1,157 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { DATAVERSE_API_PATH } from '../../nodes/Microsoft/Dataverse/constants'; +import { MicrosoftDataverseOAuth2Api } from '../MicrosoftDataverseOAuth2Api.credentials'; + +describe('MicrosoftDataverseOAuth2Api Credential', () => { + const credential = new MicrosoftDataverseOAuth2Api(); + + const propertyNamed = (name: string): INodeProperties | undefined => + credential.properties.find((property) => property.name === name); + + describe('metadata', () => { + it('has the correct static metadata', () => { + expect(credential.name).toBe('microsoftDataverseOAuth2Api'); + expect(credential.extends).toEqual(['microsoftOAuth2Api']); + expect(credential.displayName).toBe('Microsoft Dataverse OAuth2 API'); + expect(credential.documentationUrl).toBe('microsoftdataverse'); + }); + }); + + describe('static property defaults', () => { + it('offers exactly the two supported grant types and defaults to authorization code', () => { + const grantType = propertyNamed('grantType'); + expect(grantType?.type).toBe('options'); + expect(grantType?.default).toBe('authorizationCode'); + expect(grantType?.options?.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'authorizationCode', + 'clientCredentials', + ]); + }); + + it('requires the tenant ID and defaults it to "common"', () => { + const tenantId = propertyNamed('tenantId'); + expect(tenantId?.type).toBe('string'); + expect(tenantId?.default).toBe('common'); + expect(tenantId?.required).toBe(true); + }); + + it('requires a URL-validated environment URL with no default', () => { + const environmentUrl = propertyNamed('environmentUrl'); + expect(environmentUrl?.type).toBe('string'); + expect(environmentUrl?.default).toBe(''); + expect(environmentUrl?.required).toBe(true); + expect(environmentUrl?.validateType).toBe('url'); + }); + + it('sends authentication via the header', () => { + const authentication = propertyNamed('authentication'); + expect(authentication?.type).toBe('hidden'); + expect(authentication?.default).toBe('header'); + }); + + it('offers the four national clouds and defaults to global', () => { + const cloud = propertyNamed('cloud'); + expect(cloud?.type).toBe('options'); + expect(cloud?.default).toBe('global'); + expect(cloud?.options?.map((option) => ('value' in option ? option.value : ''))).toEqual([ + 'global', + 'usgov', + 'dod', + 'china', + ]); + }); + + it('hides the inherited Microsoft Graph API base URL field', () => { + const graphApiBaseUrl = propertyNamed('graphApiBaseUrl'); + expect(graphApiBaseUrl?.type).toBe('hidden'); + }); + + it('does not override the inherited account-picker auth query parameters', () => { + // Leaving it unset keeps the parent's `prompt=select_account`, which forces + // the account picker and avoids silent wrong-tenant sign-in. + expect(propertyNamed('authQueryParameters')).toBeUndefined(); + }); + }); + + describe('$self expressions', () => { + it('derives the authorization and token URLs from the national cloud and tenant ID', () => { + expect(propertyNamed('authUrl')?.default).toBe( + '={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/authorize" }}', + ); + expect(propertyNamed('accessTokenUrl')?.default).toBe( + '={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/token" }}', + ); + }); + + // Evaluate the login-host ternary the way n8n would, so the sovereign-cloud + // mapping is guarded behaviorally, not just by string match. + const loginHost = (cloud: string) => + cloud === 'china' + ? 'https://login.partner.microsoftonline.cn' + : cloud === 'usgov' || cloud === 'dod' + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + + it.each([ + ['global', 'https://login.microsoftonline.com'], + ['usgov', 'https://login.microsoftonline.us'], + ['dod', 'https://login.microsoftonline.us'], + ['china', 'https://login.partner.microsoftonline.cn'], + ])('routes %s auth to the matching sovereign login host', (cloud, expected) => { + expect(loginHost(cloud)).toBe(expected); + }); + + it('normalizes the environment URL and adds offline_access only for the authorization code flow', () => { + // Client Credentials must omit offline_access — Entra rejects it with AADSTS70011. + expect(propertyNamed('scope')?.default).toBe( + '={{ $self["environmentUrl"].trim().replace(/\\/+$/, "") + "/.default" + ($self["grantType"] === "clientCredentials" ? "" : " offline_access") }}', + ); + }); + + // Evaluate the scope expression the way n8n would, so trailing-slash + // normalization is guarded behaviorally rather than by string match alone. + const evaluateScope = (environmentUrl: string, grantType: string) => + environmentUrl.trim().replace(/\/+$/, '') + + '/.default' + + (grantType === 'clientCredentials' ? '' : ' offline_access'); + + it.each([ + 'https://yourorg.crm.dynamics.com', + 'https://yourorg.crm.dynamics.com/', + 'https://yourorg.crm.dynamics.com//', + ' https://yourorg.crm.dynamics.com/ ', + ])('collapses trailing slashes and whitespace to a single .default scope (%s)', (input) => { + expect(evaluateScope(input, 'authorizationCode')).toBe( + 'https://yourorg.crm.dynamics.com/.default offline_access', + ); + }); + + it('omits offline_access for the client credentials flow', () => { + expect(evaluateScope('https://yourorg.crm.dynamics.com/', 'clientCredentials')).toBe( + 'https://yourorg.crm.dynamics.com/.default', + ); + }); + }); + + describe('credential test', () => { + it('probes WhoAmI so setup errors surface on save', () => { + const request = credential.test.request; + expect(request.method).toBe('GET'); + expect(request.url).toBe('/api/data/v9.2/WhoAmI'); + expect(request.headers).toEqual({ Accept: 'application/json' }); + }); + + it('keeps the test API version in sync with DATAVERSE_API_PATH', () => { + // The test URL hardcodes the version because a credential can't idiomatically + // import node constants; this guard fails if the two ever drift. + expect(credential.test.request.url).toBe(`${DATAVERSE_API_PATH}/WhoAmI`); + }); + + it('normalizes the environment URL for the test base URL', () => { + expect(credential.test.request.baseURL).toBe( + '={{ $credentials.environmentUrl.trim().replace(/\\/+$/, "") }}', + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/GenericFunctions.ts new file mode 100644 index 00000000000..3b6fc15ac8e --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/GenericFunctions.ts @@ -0,0 +1,462 @@ +import { sleep } from '@n8n/utils/sleep'; +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { DATAVERSE_API_PATH, buildUserAgent } from './constants'; + +/** Headers we send to Dataverse must be string-valued. */ +export type DataverseHeaders = Record; +/** Query-string parameters Dataverse accepts. Numbers cover `$top`. */ +export type DataverseQuery = Record; + +export interface DataverseFullResponse { + body: IDataObject; + headers: Record; + statusCode: number; +} + +/** + * Contexts that can issue Dataverse requests. Node execution uses + * `IExecuteFunctions`; the in-editor `loadOptions` pickers use + * `ILoadOptionsFunctions`. Both expose the same subset we rely on here + * (`getCredentials`, `getNode`, `helpers.httpRequestWithAuthentication`), so + * the request helpers accept either and the pickers don't need their own copy + * of the base-URL / header / retry plumbing. + */ +export type DataverseContext = IExecuteFunctions | ILoadOptionsFunctions; + +/** + * Static OData v4 headers attached to every Dataverse request unless the + * caller overrides them. Pulled into a constant so the headers stay in sync + * between single-shot and paged requests. + */ +const ODATA_DEFAULT_HEADERS: DataverseHeaders = { + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', +}; + +const CONTENT_TYPE_JSON = 'application/json; charset=utf-8'; + +/** + * Per-request HTTP timeout (milliseconds). Dataverse occasionally takes 30+ + * seconds for large list / metadata queries; a generous one-minute cap keeps + * a hung request from blocking an n8n executor indefinitely while still + * allowing legitimately slow operations to complete. + */ +const REQUEST_TIMEOUT_MS = 60_000; + +/** + * HTTP status codes we treat as transient and retry. 429 covers Dataverse + * per-app throttling; 503/504 cover transient service/gateway failures. + */ +const RETRYABLE_STATUS_CODES: ReadonlySet = new Set([429, 503, 504]); + +/** + * Socket-level error codes we treat as transient and retry. These arrive with + * no `statusCode` (the request never got an HTTP response), so without this set + * they would bypass the back-off logic and fail on the first attempt. Covers + * the connection drops / DNS / timeout failures that internet-facing Dataverse + * calls hit most often. `ECONNABORTED` is axios' own client-side request + * timeout (from the `timeout` option), distinct from the socket-level + * `ETIMEDOUT`. A bare "socket hang up" (no `code`) is handled separately in + * {@link getNetworkErrorCode}. + */ +const RETRYABLE_NETWORK_CODES: ReadonlySet = new Set([ + 'ECONNRESET', + 'ETIMEDOUT', + 'ECONNABORTED', + 'ECONNREFUSED', + 'EPIPE', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'EHOSTDOWN', +]); + +/** + * HTTP methods whose repetition has no additional server-side effect, so + * retrying one after an *ambiguous* failure — where Dataverse may already have + * processed the request but the response was lost (504 gateway timeout, + * `ECONNRESET`, socket hang-up, etc.) — is safe. Both write verbs are excluded: + * `POST` (Create Row) would insert a duplicate record, and `PATCH` (Update / + * Upsert Row) can re-trigger Dataverse plugins and business logic even though + * the record-level result is idempotent. Writes only retry on failures proven + * to have never reached Dataverse (see {@link isRetryable}). + */ +const IDEMPOTENT_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', + 'OPTIONS', + 'PUT', + 'DELETE', +]); + +/** + * Failures that guarantee the request never reached Dataverse, making a retry + * safe even for a non-idempotent write (`POST` / `PATCH`). HTTP 429 is a + * throttle rejection issued before any processing; the network codes are + * connection-establishment failures (DNS / refused / no route) where the + * request body was never sent. + */ +const PRE_DELIVERY_STATUS_CODES: ReadonlySet = new Set([429]); +const PRE_DELIVERY_NETWORK_CODES: ReadonlySet = new Set([ + 'ECONNREFUSED', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'EHOSTDOWN', +]); + +/** + * Retry policy: up to `MAX_RETRIES` extra attempts (so 1 + 3 = 4 total + * dispatches in the worst case). Back-off is exponential starting from + * `BASE_DELAY_MS` with full jitter, capped at `MAX_DELAY_MS`. A server-sent + * `Retry-After` header (seconds or HTTP-date) overrides the computed delay, + * clamped to `MAX_RETRY_AFTER_MS` so a large server-dictated wait can't stall + * an executor across every attempt. + */ +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 500; +const MAX_DELAY_MS = 30_000; +const MAX_RETRY_AFTER_MS = 120_000; + +interface TransientErrorShape { + statusCode?: number; + httpCode?: number | string; + code?: string; + message?: string; + cause?: { + code?: string; + response?: { headers?: Record }; + }; + response?: { headers?: Record }; +} + +function getStatusCode(error: unknown): number | undefined { + const e = error as TransientErrorShape | null | undefined; + const raw = e?.statusCode ?? e?.httpCode; + if (typeof raw === 'number') return raw; + if (typeof raw === 'string') { + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + return undefined; +} + +/** + * Extract a transient network error code from an error. Reads `error.code` + * directly, falls back to `error.cause.code` (undici / got wrap the socket + * error under `cause`), and recognizes the classic message-only "socket hang + * up" failure that arrives without a `code`. Normalized to upper-case. + */ +function getNetworkErrorCode(error: unknown): string | undefined { + const e = error as TransientErrorShape | null | undefined; + const code = e?.code ?? e?.cause?.code; + if (typeof code === 'string' && code) return code.toUpperCase(); + if (typeof e?.message === 'string' && /socket hang up/i.test(e.message)) return 'ECONNRESET'; + return undefined; +} + +/** + * Whether a failed request should be retried. For idempotent methods, retries + * fire on any known-transient failure — a throttling / gateway status + * (429 / 503 / 504) or a transient socket / DNS error with no HTTP response. + * For non-idempotent writes (`POST` / `PATCH`), retries are restricted to + * failures that prove the request never reached Dataverse (429 or a + * connection-establishment error), so a lost response can never silently + * duplicate a created row or re-run Dataverse plugins / business logic. + */ +function isRetryable(error: unknown, method: IHttpRequestMethods): boolean { + const idempotent = IDEMPOTENT_METHODS.has(method.toUpperCase()); + const status = getStatusCode(error); + if (status !== undefined) { + // An HTTP status means the request reached the server. Ambiguous + // gateway/service failures (503 / 504) are only safe to retry when the + // method is idempotent; 429 is always safe (rejected before processing). + if (idempotent && RETRYABLE_STATUS_CODES.has(status)) return true; + return PRE_DELIVERY_STATUS_CODES.has(status); + } + const networkCode = getNetworkErrorCode(error); + if (networkCode === undefined) return false; + if (idempotent) return RETRYABLE_NETWORK_CODES.has(networkCode); + return PRE_DELIVERY_NETWORK_CODES.has(networkCode); +} + +/** + * Read a `Retry-After` delay (ms) from a failed request. Checks `error.response` + * directly, then falls back to `error.cause.response` — `httpRequestWithAuthentication` + * wraps the axios failure in `NodeApiError` and keeps the original (with its + * `response.headers`) under `cause`, mirroring {@link getNetworkErrorCode}. The + * result is clamped to `MAX_RETRY_AFTER_MS`. + */ +function parseRetryAfter(error: unknown): number | undefined { + const e = error as TransientErrorShape | null | undefined; + const headers = e?.response?.headers ?? e?.cause?.response?.headers; + if (!headers) return undefined; + const raw = headers['retry-after'] ?? headers['Retry-After']; + if (!raw) return undefined; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value === undefined) return undefined; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, seconds * 1000)); + const dateMs = Date.parse(String(value)); + if (Number.isFinite(dateMs)) + return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, dateMs - Date.now())); + return undefined; +} + +function backoffDelay(attempt: number): number { + const exponential = BASE_DELAY_MS * 2 ** attempt; + const jitter = Math.floor(Math.random() * BASE_DELAY_MS); + return Math.min(MAX_DELAY_MS, exponential + jitter); +} + +/** + * Dispatch a request via `helpers.httpRequestWithAuthentication`, retrying on + * transient failures — HTTP 429 / 503 / 504 and socket-level network errors + * (`ECONNRESET`, `ETIMEDOUT`, DNS failures, socket hang-ups). Retries are + * gated by {@link isRetryable}, which narrows the eligible failures for + * non-idempotent writes (POST) so a Create Row whose response was lost is never + * silently duplicated. Non-transient failures are re-thrown immediately so the + * caller can wrap them in `NodeApiError` without an extra delay. + */ +async function dispatchWithRetry( + ctx: DataverseContext, + credentialType: string, + options: IHttpRequestOptions, +): Promise { + const method = options.method ?? 'GET'; + let lastError: unknown; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await ctx.helpers.httpRequestWithAuthentication.call(ctx, credentialType, options); + } catch (error) { + lastError = error; + if (!isRetryable(error, method) || attempt === MAX_RETRIES) { + // Caller (`dataverseApiRequest` / `dataverseApiRequestAllItems`) + // wraps the upstream failure in `NodeApiError`. Wrapping here + // would double-wrap and lose the original Dataverse metadata. + throw error; + } + const retryAfter = parseRetryAfter(error); + await sleep(retryAfter ?? backoffDelay(attempt)); + } + } + // Unreachable — the loop either returns or throws — but the type checker + // can't see that, so re-throw the last observed failure as a safety net. + throw lastError as Error; +} + +/** + * Resolve the Dataverse environment base URL from the n8n-stored credential, + * stripping trailing slashes so `${baseUrl}${DATAVERSE_API_PATH}…` always has + * exactly one slash between segments. Matches the credential's scope and + * baseURL normalization (`/\/+$/`) so all three agree. Throws early with an + * actionable message when the credential has no Environment URL, rather than + * letting a malformed request fail obscurely at the HTTP layer. + */ +async function resolveBaseUrl(ctx: DataverseContext, credentialType: string): Promise { + const credentials = await ctx.getCredentials(credentialType); + const url = String(credentials.environmentUrl ?? '') + .trim() + .replace(/\/+$/, ''); + if (!url) { + throw new NodeApiError(ctx.getNode(), { + message: 'Dataverse credential is missing "Environment URL"', + description: + 'Open the Microsoft Dataverse credential and set Environment URL to e.g. https://yourorg.crm.dynamics.com, then reconnect.', + } as JsonObject); + } + return url; +} + +/** + * Build and dispatch an authenticated Dataverse request, returning the raw + * response and letting the *original* upstream error propagate (no + * `NodeApiError` wrapping). This is the shared core behind + * {@link dataverseApiRequest}; callers that need to surface their own error + * shape — e.g. the `loadOptions` pickers, which extract the real Dataverse + * message for the dropdown — use this directly and wrap failures themselves. + */ +export async function dataverseApiRequestRaw( + ctx: DataverseContext, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: DataverseQuery = {}, + headers: DataverseHeaders = {}, + credentialType: string, + returnFullResponse = false, +): Promise { + const baseUrl = await resolveBaseUrl(ctx, credentialType); + let url: string; + if (/^https?:\/\//i.test(resource)) { + // An absolute resource is a server-issued `@odata.nextLink` (server-driven + // paging). Require it to stay within the configured environment so a tampered + // saved token can't redirect the authenticated request to another host. + if (!resource.toLowerCase().startsWith(`${baseUrl.toLowerCase()}/`)) { + throw new NodeApiError(ctx.getNode(), { + message: + 'Refusing to follow a Dataverse pagination link outside the configured Environment URL', + } as JsonObject); + } + url = resource; + } else { + url = `${baseUrl}${DATAVERSE_API_PATH}${resource}`; + } + + const options: IHttpRequestOptions = { + method, + body, + qs, + url, + headers: { + ...ODATA_DEFAULT_HEADERS, + 'User-Agent': buildUserAgent(ctx.getNode().typeVersion), + 'Content-Type': CONTENT_TYPE_JSON, + ...headers, + }, + json: true, + timeout: REQUEST_TIMEOUT_MS, + ...(returnFullResponse ? { returnFullResponse: true } : {}), + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + return (await dispatchWithRetry(ctx, credentialType, options)) as + | IDataObject + | DataverseFullResponse; +} + +/** + * Make an authenticated request against the Dataverse Web API. + * + * Auth (OAuth2 token acquisition and the Bearer header) is handled by the + * `microsoftDataverseOAuth2Api` credential, so this helper only builds the + * request and delegates to `requestWithAuthentication` (via `dispatchWithRetry`, + * which adds transient-failure retries with exponential back-off + jitter). + * + * The caller MUST pass the credential type explicitly so a future refactor + * cannot accidentally fall back to a stale literal that does not match any + * registered credential. + */ +export async function dataverseApiRequest( + ctx: IExecuteFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: DataverseQuery = {}, + headers: DataverseHeaders = {}, + credentialType: string, +): Promise { + try { + return (await dataverseApiRequestRaw( + ctx, + method, + resource, + body, + qs, + headers, + credentialType, + )) as IDataObject; + } catch (error) { + throw new NodeApiError(ctx.getNode(), error as JsonObject); + } +} + +export async function dataverseApiRequestWithResponse( + ctx: IExecuteFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: DataverseQuery = {}, + headers: DataverseHeaders = {}, + credentialType: string, +): Promise { + try { + return (await dataverseApiRequestRaw( + ctx, + method, + resource, + body, + qs, + headers, + credentialType, + true, + )) as DataverseFullResponse; + } catch (error) { + throw new NodeApiError(ctx.getNode(), error as JsonObject); + } +} + +/** + * Fetch all pages of a Dataverse collection, following ``@odata.nextLink``. + * + * Dataverse controls the page size unless the caller explicitly supplies a + * `Prefer` header. All headers are forwarded as-is. Each page dispatch uses + * the same retry-with-back-off policy as single-shot requests. + */ +export async function dataverseApiRequestAllItems( + ctx: IExecuteFunctions, + method: IHttpRequestMethods, + resource: string, + qs: DataverseQuery = {}, + limit = 0, + credentialType: string, + extraHeaders: DataverseHeaders = {}, +): Promise { + const baseUrl = await resolveBaseUrl(ctx, credentialType); + + const headers: DataverseHeaders = { + ...ODATA_DEFAULT_HEADERS, + 'User-Agent': buildUserAgent(ctx.getNode().typeVersion), + ...extraHeaders, + }; + + const results: IDataObject[] = []; + let url = `${baseUrl}${DATAVERSE_API_PATH}${resource}`; + let useQs: DataverseQuery | undefined = qs; + + do { + const options: IHttpRequestOptions = { + method, + url, + qs: useQs, + headers, + json: true, + timeout: REQUEST_TIMEOUT_MS, + }; + + let response: IDataObject; + try { + response = (await dispatchWithRetry(ctx, credentialType, options)) as IDataObject; + } catch (error) { + throw new NodeApiError(ctx.getNode(), error as JsonObject); + } + + const page = (response.value as IDataObject[]) ?? []; + for (const item of page) { + results.push(item); + if (limit > 0 && results.length >= limit) { + return results; + } + } + + url = (response['@odata.nextLink'] as string) ?? ''; + // nextLink already encodes the query string, so don't resend qs. + useQs = undefined; + } while (url); + + return results; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.json b/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.json new file mode 100644 index 00000000000..ff27ae53c6e --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.microsoftDataverse", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage", "Finance & Accounting", "Sales", "Development"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.microsoftdataverse/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.ts new file mode 100644 index 00000000000..ad673249e34 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.ts @@ -0,0 +1,121 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; + +import { + getEntitySets, + getReadColumns, + getWriteColumns, + searchEntitySets, + searchRows, +} from './loadOptions'; +import { RECORD_OPERATIONS, resolveOperation, toDropdownOption } from './operations'; + +export class MicrosoftDataverse implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft Dataverse', + name: 'microsoftDataverse', + icon: 'file:microsoftDataverse.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with the Microsoft Dataverse Web API', + defaults: { name: 'Microsoft Dataverse' }, + usableAsTool: true, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'microsoftDataverseOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + default: 'row', + options: [ + { + name: 'Row', + value: 'row', + description: 'Read or write rows in a Dataverse table', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { show: { resource: ['row'] } }, + // Generated from the RECORD_OPERATIONS registry so the op list lives in + // exactly one place; the registry is ordered alphabetically by name. + options: RECORD_OPERATIONS.map(toDropdownOption), + }, + ...RECORD_OPERATIONS.flatMap((op) => op.properties), + ], + }; + + methods = { + loadOptions: { + getEntitySets, + getReadColumns, + getWriteColumns, + }, + listSearch: { + searchEntitySets, + searchRows, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + let out: INodeExecutionData[] = []; + const liveOps = RECORD_OPERATIONS.map((o) => o.value); + + for (let i = 0; i < items.length; i++) { + try { + const operation = this.getNodeParameter('operation', i) as string; + const op = resolveOperation(operation); + if (!op) { + throw new NodeOperationError( + this.getNode(), + `Unsupported operation "${operation}". Expected one of: ${liveOps.join(', ')}`, + { itemIndex: i }, + ); + } + const result = await op.execute(this, i, 'microsoftDataverseOAuth2Api'); + const rows = Array.isArray(result) ? result : [result]; + const wrapped = this.helpers.returnJsonArray(rows as IDataObject[]); + const meta = this.helpers.constructExecutionMetaData(wrapped, { + itemData: { item: i }, + }); + out = out.concat(meta); + } catch (error) { + if (this.continueOnFail()) { + const err = error as Error; + out = out.concat({ + json: { error: err.message ?? String(err) }, + pairedItem: { item: i }, + }); + continue; + } + // Op modules already wrap raw HTTP failures in NodeApiError / + // NodeOperationError. Preserve the original type so n8n's UI shows + // the right context. + throw error; + } + } + + return [out]; + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts new file mode 100644 index 00000000000..3636ceee46e --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts @@ -0,0 +1,28 @@ +/** + * Shared constants for the Microsoft Dataverse node. + * + * Keeping these in one place means a future Web API version bump (or + * scope-policy change) touches a single file instead of scattered string + * literals across credentials, GenericFunctions, and operation modules. + */ + +/** Web API base path. Update here when Microsoft ships a new version. */ +export const DATAVERSE_API_PATH = '/api/data/v9.2'; + +/** + * Product token for the `User-Agent` sent on every Dataverse Web API request. + * A descriptive agent string lets Microsoft correlate traffic to this node in + * support/telemetry scenarios. + */ +export const USER_AGENT_PREFIX = 'n8n-nodes-base.microsoftDataverse'; + +/** + * Build the `User-Agent` including the node's version so requests are + * attributable to a specific node version (e.g. `n8n-nodes-base.microsoftDataverse/1.0`). + * Pass `this.getNode().typeVersion` from the execution/load-options context. + * A whole-number version is normalized to include a minor (`1` -> `1.0`). + */ +export function buildUserAgent(version: number | string): string { + const normalized = Number.isInteger(Number(version)) ? `${Number(version)}.0` : `${version}`; + return `${USER_AGENT_PREFIX}/${normalized}`; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts new file mode 100644 index 00000000000..9680c3458a9 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts @@ -0,0 +1,414 @@ +import type { + ILoadOptionsFunctions, + INodeListSearchResult, + INodePropertyOptions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { DATAVERSE_API_PATH } from './constants'; +import { + dataverseApiRequestRaw, + type DataverseHeaders, + type DataverseQuery, +} from './GenericFunctions'; +import { isValidEntitySet, normalizeEntitySet } from './operations/shared'; + +/** + * `loadOptions` handlers for the Dataverse node. These power the in-editor + * pickers (tables and columns) so users don't have to memorize entity-set + * names or column logical names. + * + * Each handler makes one or more authenticated GET requests against the Dataverse Web + * API metadata endpoints and returns the result as `{ name, value }` + * options. n8n caches the result per parameter, so the request only runs + * when the user opens the dropdown or when a dependent parameter changes. + * + * Failures are re-thrown as `NodeApiError` so the editor surfaces the real + * HTTP error in the dropdown (auth, scope, environment URL, ...) instead of + * silently rendering an empty list — which is indistinguishable from a + * table with no entities. + */ + +interface EntityDefinition { + LogicalName: string; + EntitySetName: string | null; + PrimaryIdAttribute?: string; + PrimaryNameAttribute?: string; + DisplayName?: { + UserLocalizedLabel?: { Label?: string }; + }; +} + +interface AttributeDefinition { + LogicalName: string; + AttributeOf?: string | null; + IsValidODataAttribute?: boolean; + IsValidForRead?: boolean; + IsValidForCreate?: boolean; + IsValidForUpdate?: boolean; + AttributeType?: string; + DisplayName?: { + UserLocalizedLabel?: { Label?: string }; + }; +} + +interface ODataCollection { + value: T[]; +} + +function labelOf(def: { DisplayName?: { UserLocalizedLabel?: { Label?: string } } }): + | string + | null { + // Treat blank / whitespace labels as missing so the caller falls through + // to the LogicalName rather than rendering an ugly `" (accounts)"`. + const raw = def.DisplayName?.UserLocalizedLabel?.Label; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parameterValue(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'object' && value !== null && 'value' in value) { + return typeof value.value === 'string' ? value.value.trim() : ''; + } + return ''; +} + +async function dataverseGet( + ctx: ILoadOptionsFunctions, + path: string, + qs: DataverseQuery = {}, + headers: DataverseHeaders = {}, +): Promise { + try { + // Delegate base-URL resolution, OData headers, User-Agent, and + // transient-failure retries to the shared request helper. The `Raw` variant + // skips the extra NodeApiError wrap that `dataverseApiRequest` adds, but + // `httpRequestWithAuthentication` still pre-wraps the failure in one; we + // unwrap it below so our extracted message survives. + return (await dataverseApiRequestRaw( + ctx, + 'GET', + path, + {}, + qs, + headers, + 'microsoftDataverseOAuth2Api', + )) as T; + } catch (error) { + // `httpRequestWithAuthentication` wraps the upstream HTTP failure in a + // NodeApiError. The real Dataverse text can live in any of: + // - `error.description` — n8n's own NodeApiError text field, which + // usually holds the parsed upstream error message. + // - `error.cause.response.body` — axios envelope under the wrapper. + // - `error.response.body` — older n8n shape. + // Crucially, n8n sets `error.message = "Bad request - please check your + // parameters"` for any 4xx, which is useless on its own. And Dataverse + // metadata endpoints can return the OData v3 `message: { lang, value }` + // shape, which would render as "[object Object]" if interpolated raw. + const { status, dvCode, dvMessage } = extractDataverseError(error); + const prefix = `Failed to load options from Dataverse${status ? ` (HTTP ${status})` : ''}`; + const codePart = dvCode ? ` [${dvCode}]` : ''; + const message = `${prefix}${codePart}: ${dvMessage}`; + const description = `GET ${DATAVERSE_API_PATH}${path}`; + // The failure is already a NodeApiError; its constructor short-circuits when + // re-wrapped (ignoring message/description), so mutate it in place instead. + if (error instanceof NodeApiError) { + error.message = message; + error.description = description; + throw error; + } + throw new NodeApiError(ctx.getNode(), error as JsonObject, { message, description }); + } +} + +/** + * Drill through n8n's HTTP wrapper, axios's response envelope, and + * Dataverse's two error-body shapes (v9.x string-message vs legacy + * v3 object-message) to recover the actual `(httpStatus, errorCode, + * humanMessage)` triple. Always returns strings — never an object — so + * the caller can interpolate without producing "[object Object]". + */ +function extractDataverseError(error: unknown): { + status: string; + dvCode: string; + dvMessage: string; +} { + const err = error as { + message?: unknown; + description?: unknown; + statusCode?: number | string; + httpCode?: number | string; + cause?: { + response?: { body?: unknown; statusCode?: number }; + statusCode?: number; + body?: unknown; + }; + response?: { body?: unknown; statusCode?: number }; + }; + const status = String( + err.httpCode ?? err.statusCode ?? err.cause?.statusCode ?? err.response?.statusCode ?? '', + ); + // Walk every place a Dataverse JSON body could live. + const candidates: unknown[] = [ + err.response?.body, + err.cause?.response?.body, + err.cause?.body, + (err as { error?: unknown }).error, + ]; + for (const body of candidates) { + const parsed = parseDvBody(body); + if (parsed.message) return { status, dvCode: parsed.code, dvMessage: parsed.message }; + } + // n8n's NodeApiError populates `.description` with the upstream error text + // even when the body isn't reachable via response/cause (e.g. it pre-parsed + // and discarded the body). Treat the wrapper's own message as a last resort. + if (typeof err.description === 'string' && err.description) { + return { status, dvCode: '', dvMessage: err.description }; + } + const fallback = typeof err.message === 'string' ? err.message : safeStringify(err.message); + return { status, dvCode: '', dvMessage: fallback || 'unknown error' }; +} + +function parseDvBody(body: unknown): { code: string; message: string } { + if (!body) return { code: '', message: '' }; + let obj: { error?: { code?: string; message?: unknown }; message?: unknown } | undefined; + if (typeof body === 'string') { + // Body might be a raw JSON string n8n didn't parse. + try { + obj = JSON.parse(body) as typeof obj; + } catch { + return { code: '', message: body }; + } + } else if (typeof body === 'object') { + obj = body as typeof obj; + } + if (!obj) return { code: '', message: '' }; + const code = obj.error?.code ?? ''; + const rawMessage = obj.error?.message ?? obj.message; + let message = ''; + if (typeof rawMessage === 'string') { + message = rawMessage; + } else if (rawMessage && typeof rawMessage === 'object') { + // Legacy OData v3 shape: { lang, value } + const v = (rawMessage as { value?: unknown }).value; + if (typeof v === 'string') message = v; + else message = safeStringify(rawMessage); + } + return { code: String(code ?? ''), message }; +} + +function safeStringify(value: unknown): string { + if (value === undefined || value === null) return ''; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * List every Dataverse table the user is likely to want to interact with. + * + * We deliberately keep the server-side `$filter` to simple primitive + * Booleans only (`IsIntersect`, `IsLogicalEntity`, `IsPrivate`) — Dataverse + * environments differ in whether they accept `$filter` against complex + * managed-properties such as `IsValidForAdvancedFind/Value` and several + * environments return 400 Bad Request for that shape. Everything else + * (entities without an EntitySetName, system audit/sync tables, etc.) is + * filtered client-side. + * + * Note: the Dataverse metadata API (`EntityDefinitions`) does NOT support + * `$top`, `$skip`, `$count`, `$orderby`, `$search`, or `$apply` — only + * `$select`, `$filter`, and `$expand`. Asking for `$top=500` returns + * `400 Bad Request: "The query parameter $top is not supported"`. We + * therefore fetch the full filtered set and sort it client-side. + * + * The dropdown is sorted by display name to match the n8n editor's other + * table pickers (Airtable, Postgres, etc.). The full filtered set is + * returned — the `$select` projection keeps each row tiny (3 fields), so + * even large environments (800+ tables) stay well within a manageable + * payload. + */ +export async function getEntitySets(this: ILoadOptionsFunctions): Promise { + const response = await dataverseGet>( + this, + '/EntityDefinitions', + { + // Only project fields we actually consume client-side. The three + // `Is*` booleans are filtered server-side via `$filter`, so there's + // no reason to ship them back over the wire. + $select: 'LogicalName,EntitySetName,DisplayName', + $filter: 'IsIntersect eq false and IsLogicalEntity eq false and IsPrivate eq false', + }, + ); + const options: INodePropertyOptions[] = []; + for (const def of response.value ?? []) { + if (!def.EntitySetName) continue; + const label = labelOf(def) ?? def.LogicalName; + options.push({ + name: `${label} (${def.EntitySetName})`, + value: def.EntitySetName, + description: `Logical name: ${def.LogicalName}`, + }); + } + options.sort((a, b) => a.name.localeCompare(b.name)); + return options; +} + +export async function searchEntitySets( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const filterLower = filter?.trim().toLowerCase(); + const options = await getEntitySets.call(this); + const results = options + .filter((option) => !filterLower || option.name.toLowerCase().includes(filterLower)) + .map(({ name, value }) => ({ name, value })); + return { results }; +} + +export async function searchRows( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const entitySet = normalizeEntitySet(this.getCurrentNodeParameter('entitySet')); + if (!entitySet) return { results: [] }; + if (!isValidEntitySet(entitySet)) { + throw new NodeApiError(this.getNode(), { + message: 'Table name must be a valid Dataverse table name', + } as JsonObject); + } + + // Resolve the primary id/name attributes used to map rows. Runs on every + // invocation, including each pagination page: the nextLink is opaque to us, so + // we can't thread these through it — one extra metadata GET per page. + const metadata = await dataverseGet>( + this, + '/EntityDefinitions', + { + $select: 'PrimaryIdAttribute,PrimaryNameAttribute', + $filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`, + }, + ); + const { PrimaryIdAttribute: idAttribute, PrimaryNameAttribute: nameAttribute } = + metadata.value?.[0] ?? {}; + if (!idAttribute) return { results: [] }; + + // `Prefer: odata.maxpagesize` gives server-driven paging with an + // `@odata.nextLink`, unlike `$top`, which hard-caps the result set with no + // continuation and silently hides rows past the cap. + const pageHeaders: DataverseHeaders = { Prefer: 'odata.maxpagesize=100' }; + type RowPage = ODataCollection> & { '@odata.nextLink'?: string }; + let response: RowPage; + if (paginationToken) { + // The nextLink already encodes `$select`/`$filter` and the page cursor. + response = await dataverseGet(this, paginationToken, {}, pageHeaders); + } else { + const qs: DataverseQuery = { + $select: nameAttribute ? `${idAttribute},${nameAttribute}` : idAttribute, + }; + const trimmedFilter = filter?.trim(); + if (trimmedFilter && nameAttribute) { + qs.$filter = `contains(${nameAttribute},'${trimmedFilter.replace(/'/g, "''")}')`; + } + response = await dataverseGet(this, `/${entitySet}`, qs, pageHeaders); + } + + const results = (response.value ?? []).flatMap((row) => { + const id = row[idAttribute]; + if (typeof id !== 'string' || !id) return []; + const name = nameAttribute ? row[nameAttribute] : undefined; + return [{ name: typeof name === 'string' && name ? `${name} (${id})` : id, value: id }]; + }); + const nextLink = response['@odata.nextLink']; + return typeof nextLink === 'string' && nextLink + ? { results, paginationToken: nextLink } + : { results }; +} + +/** + * List columns for the currently-selected table. Requires the caller to set + * `loadOptionsDependsOn: ['entitySet']` so the picker reloads when the user + * changes tables. + * + * The current `entitySet` parameter holds the plural URL name (e.g. + * `accounts`). The Dataverse metadata API addresses tables by their singular + * `LogicalName`, so we look up the LogicalName first, then request + * attributes. Two short metadata calls. + */ +async function getColumns( + ctx: ILoadOptionsFunctions, + mode: 'read' | 'write', +): Promise { + const entitySet = parameterValue(ctx.getCurrentNodeParameter('entitySet')); + if (!entitySet) return []; + // `EntitySetName eq ''` — the single quotes are OData literal + // syntax (not URL syntax), so they go in the value verbatim. The HTTP + // layer URL-encodes spaces/quotes; do not pre-encode. + const lookup = await dataverseGet>( + ctx, + '/EntityDefinitions', + { + $select: 'LogicalName', + $filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`, + }, + ); + const logicalName = lookup.value?.[0]?.LogicalName; + if (!logicalName) return []; + // Logical names are restricted to [a-z0-9_], so no encoding needed for the + // path segment; the HTTP layer encodes the single quotes around it. + const attrs = await dataverseGet>( + ctx, + `/EntityDefinitions(LogicalName='${logicalName}')/Attributes`, + { + $select: + 'LogicalName,DisplayName,AttributeOf,IsValidODataAttribute,IsValidForRead,IsValidForCreate,IsValidForUpdate,AttributeType', + }, + ); + const operation = parameterValue(ctx.getCurrentNodeParameter('operation')); + const options: INodePropertyOptions[] = []; + for (const attr of attrs.value ?? []) { + // Skip virtual sub-attributes (e.g. name, yominame) — + // they have an AttributeOf set and aren't queryable in their own right. + if (attr.AttributeOf) continue; + if (attr.IsValidODataAttribute === false) continue; + if (mode === 'read' && attr.IsValidForRead === false) continue; + if (mode === 'write') { + if (operation === 'create' && attr.IsValidForCreate !== true) continue; + if (operation === 'update' && attr.IsValidForUpdate !== true) continue; + if ( + operation !== 'create' && + operation !== 'update' && + (attr.IsValidForCreate !== true || attr.IsValidForUpdate !== true) + ) { + continue; + } + } + const label = labelOf(attr) ?? attr.LogicalName; + const isLookup = + attr.AttributeType === 'Lookup' || + attr.AttributeType === 'Customer' || + attr.AttributeType === 'Owner'; + const name = isLookup + ? `${label} (${attr.LogicalName}) — lookup` + : `${label} (${attr.LogicalName})`; + const value = mode === 'read' && isLookup ? `_${attr.LogicalName}_value` : attr.LogicalName; + options.push({ name, value }); + } + options.sort((a, b) => a.name.localeCompare(b.name)); + return options; +} + +export async function getReadColumns(this: ILoadOptionsFunctions): Promise { + return await getColumns(this, 'read'); +} + +export async function getWriteColumns( + this: ILoadOptionsFunctions, +): Promise { + return await getColumns(this, 'write'); +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/microsoftDataverse.svg b/packages/nodes-base/nodes/Microsoft/Dataverse/microsoftDataverse.svg new file mode 100644 index 00000000000..4230ccd12ca --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/microsoftDataverse.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/createRow.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/createRow.ts new file mode 100644 index 00000000000..d50433fbc5f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/createRow.ts @@ -0,0 +1,60 @@ +import type { IDataObject } from 'n8n-workflow'; +import type { OperationDefinition } from './types'; +import { + applyLookupBindings, + bodyHasLookupCandidates, + EMPTY_LOOKUP_FIELDS, + resolveLookupFields, +} from './lookups'; +import { assertNonEmptyBody, assertValidEntitySet, executeRequest, parseItemInput } from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonReturnFullMetadataOption, + commonReturnSessionTokenOption, + commonRowItemProperties, + commonSessionTokenOption, +} from './sharedProperties'; + +/** + * dv connector — "Add a new row" (`CreateRecord`). + * + * `POST /{entitySet}` with `Prefer: return=representation` so the created + * record (including the server-generated GUID) is echoed back to the user. + */ +export const createRow: OperationDefinition = { + displayName: 'Create', + value: 'create', + description: 'Add a new row to a Microsoft Dataverse table', + action: 'Create a row', + properties: [ + commonEntitySetProperty(['create']), + ...commonRowItemProperties(['create']), + buildOptionsCollection('create', [ + commonReturnFullMetadataOption(), + commonSessionTokenOption(), + commonReturnSessionTokenOption(), + ]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + // Validate before resolving lookup metadata so an empty Row Item fails fast + // without spending metadata requests. Lookup metadata is only resolved when the + // body actually carries a lookup-style value, so a plain write stays a single + // request and needs no metadata-read permission. + const rawBody = assertNonEmptyBody(ctx, i, parseItemInput(ctx, i), 'Create'); + const lookupFields = bodyHasLookupCandidates(rawBody) + ? await resolveLookupFields(ctx, credentialType, entitySet) + : EMPTY_LOOKUP_FIELDS; + const body = applyLookupBindings(ctx, i, rawBody, lookupFields); + const options = ctx.getNodeParameter('createOptions', i, {}) as IDataObject; + return await executeRequest(ctx, credentialType, { + method: 'POST', + path: `/${entitySet}`, + body, + options, + prefer: { returnRepresentation: true }, + returnSessionToken: Boolean(options.returnSessionToken), + }); + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/deleteRow.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/deleteRow.ts new file mode 100644 index 00000000000..bc3c59b9d66 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/deleteRow.ts @@ -0,0 +1,43 @@ +import type { IDataObject } from 'n8n-workflow'; +import type { OperationDefinition } from './types'; +import { + assertValidEntitySet, + assertValidRecordId, + buildRecordPath, + executeRequest, +} from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonPartitionIdOption, + commonRecordIdProperty, +} from './sharedProperties'; + +/** + * dv connector — "Delete a row" (`DeleteRecord`). + * + * `DELETE /{entitySet}({recordId})`. Dataverse responds 204 No Content on + * success; we surface a small synthetic JSON payload so downstream nodes can + * branch on `success`. + */ +export const deleteRow: OperationDefinition = { + displayName: 'Delete', + value: 'delete', + description: 'Delete a row from a Microsoft Dataverse table', + action: 'Delete a row', + properties: [ + commonEntitySetProperty(['delete']), + commonRecordIdProperty(['delete']), + buildOptionsCollection('delete', [commonPartitionIdOption()]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i)); + await executeRequest(ctx, credentialType, { + method: 'DELETE', + path: buildRecordPath(entitySet, recordId), + options: ctx.getNodeParameter('deleteOptions', i, {}) as IDataObject, + }); + return { success: true, id: recordId }; + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getManyRows.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getManyRows.ts new file mode 100644 index 00000000000..5a45b4b00ea --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getManyRows.ts @@ -0,0 +1,172 @@ +import type { IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { type DataverseQuery } from '../GenericFunctions'; +import { assertValidEntitySet, buildODataQs, executeRequest } from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonExpandOption, + commonPartitionIdOption, + commonReturnFullMetadataOption, + commonSelectOption, + commonSessionTokenOption, + forOperation, +} from './sharedProperties'; +import type { OperationDefinition } from './types'; + +const DATAVERSE_MAX_PAGE_SIZE = 5_000; + +/** + * dv connector — "Get Many" rows (`ListRecords`). + * + * `GET /{entitySet}` with the full Dataverse OData query surface: + * `$select`, `$filter`, `$orderby`, `$expand`, and `$top`, plus FetchXml, + * partitionId, and Return Full Metadata. + * + * Two paging modes: + * - **Return All**: follows `@odata.nextLink` for OData queries until exhausted. + * FetchXML queries cannot use Return All. + * - **Limit**: stops after N rows (default 50). On the OData path, limits up to + * 5,000 use `$top: N`; larger limits keep server-driven paging so Dataverse + * can return more than one page. FetchXML uses client-side capping only. + */ +export const getManyRows: OperationDefinition = { + displayName: 'Get Many', + value: 'getAll', + description: 'Get many rows in a Microsoft Dataverse table', + action: 'Get many rows', + properties: [ + commonEntitySetProperty(['getAll']), + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: forOperation(['getAll']), + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { minValue: 1 }, + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { ...forOperation(['getAll']).show, returnAll: [false] }, + }, + }, + buildOptionsCollection('getAll', [ + commonExpandOption(), + { + displayName: 'FetchXML Query', + name: 'fetchXml', + type: 'string', + typeOptions: { rows: 4 }, + default: '', + placeholder: '...', + description: + 'Advanced query — forwarded as ?fetchXml=. Overrides OData query options when set. Cannot be used with Return All.', + }, + { + displayName: 'Filter Rows', + name: 'filter', + type: 'string', + default: '', + placeholder: 'statecode eq 0', + description: 'OData $filter expression', + }, + commonPartitionIdOption(), + commonSessionTokenOption(), + commonReturnFullMetadataOption(), + { + displayName: 'Row Count ($Top)', + name: 'top', + type: 'number', + typeOptions: { minValue: 1 }, + default: 0, + description: + 'Maximum number of rows Dataverse returns without paging. When unset, Limit is used here only when it is 5,000 or less.', + }, + commonSelectOption(), + { + displayName: 'Sort Column Name or ID', + name: 'orderbyColumn', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getReadColumns', + loadOptionsDependsOn: ['entitySet.value'], + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Sort Direction', + name: 'orderbyDirection', + type: 'options', + default: 'asc', + options: [ + { name: 'Ascending', value: 'asc' }, + { name: 'Descending', value: 'desc' }, + ], + description: 'Direction for Sort Column. Ignored when Sort Column is empty.', + }, + { + displayName: 'Sort By Override', + name: 'orderby', + type: 'string', + default: '', + placeholder: 'createdon desc, name asc', + description: + 'Raw OData $orderby expression. Used only when Sort Column is empty — lets you pass multi-column sort or expressions the picker does not cover.', + }, + ]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + const returnAll = ctx.getNodeParameter('returnAll', i, false) as boolean; + const limit = returnAll ? 0 : (ctx.getNodeParameter('limit', i, 50) as number); + const options = ctx.getNodeParameter('getAllOptions', i, {}) as IDataObject; + + const orderbyColumn = (options.orderbyColumn as string) ?? ''; + const orderbyDirection = (options.orderbyDirection as string) ?? 'asc'; + const orderby = orderbyColumn + ? `${orderbyColumn} ${orderbyDirection}` + : ((options.orderby as string) ?? ''); + + const fetchXml = ((options.fetchXml as string) ?? '').trim(); + if (returnAll && fetchXml) { + throw new NodeOperationError( + ctx.getNode(), + 'FetchXML Query cannot be used with Return All because FetchXML pagination is not supported. Disable Return All and set a Limit instead.', + { itemIndex: i }, + ); + } + const userTop = typeof options.top === 'number' && options.top > 0 ? options.top : undefined; + // `$top` disables server-driven paging, so only derive it when the requested + // limit fits within one standard Dataverse page. Explicit Row Count still + // takes precedence, and FetchXML has no equivalent option. + const limitTop = limit > 0 && limit <= DATAVERSE_MAX_PAGE_SIZE ? limit : undefined; + const effectiveTop = userTop ?? limitTop; + const qs: DataverseQuery = fetchXml + ? { fetchXml } + : buildODataQs({ + select: options.select, + filter: options.filter, + orderby, + expand: options.expand, + top: effectiveTop, + }); + + return await executeRequest(ctx, credentialType, { + method: 'GET', + path: `/${entitySet}`, + qs, + options, + paged: true, + limit, + }); + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getRow.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getRow.ts new file mode 100644 index 00000000000..925c1163971 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/getRow.ts @@ -0,0 +1,54 @@ +import type { IDataObject } from 'n8n-workflow'; +import type { OperationDefinition } from './types'; +import { + assertValidEntitySet, + assertValidRecordId, + buildODataQs, + buildRecordPath, + executeRequest, +} from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonExpandOption, + commonPartitionIdOption, + commonRecordIdProperty, + commonReturnFullMetadataOption, + commonSelectOption, + commonSessionTokenOption, +} from './sharedProperties'; + +/** + * dv connector — "Get a row by ID" (`GetItem`). + * + * `GET /{entitySet}({recordId})` with optional `$select`, `$expand`, + * partitionId, and Return Full Metadata. + */ +export const getRow: OperationDefinition = { + displayName: 'Get', + value: 'get', + description: 'Get a single row by its primary key', + action: 'Get a row', + properties: [ + commonEntitySetProperty(['get']), + commonRecordIdProperty(['get']), + buildOptionsCollection('get', [ + commonSelectOption(), + commonExpandOption(), + commonReturnFullMetadataOption(), + commonPartitionIdOption(), + commonSessionTokenOption(), + ]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i)); + const options = ctx.getNodeParameter('getOptions', i, {}) as IDataObject; + return await executeRequest(ctx, credentialType, { + method: 'GET', + path: buildRecordPath(entitySet, recordId), + qs: buildODataQs({ select: options.select, expand: options.expand }), + options, + }); + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/index.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/index.ts new file mode 100644 index 00000000000..ddeac808dfb --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/index.ts @@ -0,0 +1,36 @@ +/** + * Single registry of every record-level operation the node supports. Adding a + * new operation = create a module under this folder and append it here. + */ + +import type { OperationDefinition } from './types'; +import { createRow } from './createRow'; +import { getRow } from './getRow'; +import { getManyRows } from './getManyRows'; +import { updateRow } from './updateRow'; +import { upsertRow } from './upsertRow'; +import { deleteRow } from './deleteRow'; + +export { toDropdownOption } from './types'; + +/** Display order is alphabetical by name, matching the n8n catalog convention. */ +export const RECORD_OPERATIONS: OperationDefinition[] = [ + createRow, + upsertRow, + deleteRow, + getRow, + getManyRows, + updateRow, +]; + +export const OPERATION_BY_VALUE: Record = RECORD_OPERATIONS.reduce( + (acc, op) => { + acc[op.value] = op; + return acc; + }, + {} as Record, +); + +export function resolveOperation(value: string): OperationDefinition | undefined { + return OPERATION_BY_VALUE[value]; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/lookups.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/lookups.ts new file mode 100644 index 00000000000..ed071729051 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/lookups.ts @@ -0,0 +1,446 @@ +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow'; + +import { dataverseApiRequest } from '../GenericFunctions'; +import { isElasticTable, resolveTableMetadataByLogicalNames } from './metadata'; +import { hasUrlBreakingChars, normalizeEntitySet } from './shared'; + +/** + * Lookup (relationship) column handling for write operations. + * + * Dataverse rejects a lookup's raw logical name in a create/update body. Writes + * must go through the single-valued navigation property with the `@odata.bind` + * annotation, e.g. `"primarycontactid@odata.bind": "/contacts()"`. The + * navigation property name is NOT always the logical name — for multi-table + * (polymorphic) lookups such as Customer/Owner a single logical name maps to + * several target-specific navigation properties (`customerid_account`, + * `customerid_contact`, ...). We therefore resolve the navigation property and + * target entity set from relationship metadata and rewrite the body key. + */ + +/** One writable target of a lookup column. Polymorphic lookups have several. */ +export interface LookupCandidate { + /** Single-valued navigation property to bind (ReferencingEntityNavigationPropertyName). */ + navigationProperty: string; + /** Referenced table logical name (e.g. `contact`). */ + referencedEntity: string; + /** Referenced table entity-set name used in the bind path (e.g. `contacts`). */ + targetEntitySet: string; + /** Target table classification reported by EntityMetadata.TableType. */ + tableType?: string; + /** Target table primary key used in an elastic composite reference. */ + primaryIdAttribute?: string; +} + +/** Lookup logical name (lower-cased) → its writable navigation-property targets. */ +export type LookupFieldMap = Map; + +/** Shared read-only empty map for the common "no lookup candidates" fast path. */ +export const EMPTY_LOOKUP_FIELDS: LookupFieldMap = new Map(); + +interface RelationshipRow { + ReferencingAttribute?: string; + ReferencingEntityNavigationPropertyName?: string; + ReferencedEntity?: string; +} + +const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// `entityset()` — a full navigation reference the user can paste verbatim. +const ENTITYSET_PATH_PATTERN = /^\/?[a-z][a-z0-9_]*\([^)]+\)$/i; + +/** + * Abstract lookup targets Dataverse reports in relationship metadata but that + * can't be bound directly. `ownerid` reports the abstract `owner` entity, yet + * the Web API assigns it via `/systemusers()` or `/teams()`. Expanding + * to the concrete members (sharing the one navigation property) lets both + * documented forms validate and makes a bare GUID ambiguous — the same + * disambiguation the Customer lookup already produces. + */ +const ABSTRACT_LOOKUP_TARGETS: Record = { + owner: ['systemuser', 'team'], +}; + +// Per-execution cache so a Return All / multi-item write resolves the metadata +// for a given table once, not once per item. Keyed by the execution context so +// it clears automatically when the execution is garbage-collected. +const cache = new WeakMap>>(); + +/** + * Resolve the lookup columns of `entitySet` to their navigation-property + * targets, memoized per execution. + * + * Cost: up to three metadata requests per table (resolve the logical name, list + * many-to-one relationships, resolve the referenced tables' entity-set names). + * The per-execution {@link cache} collapses this to one resolution per table, so + * a Return All / multi-item write pays it once, not once per item. + * + * Failure contract: metadata read is best-effort. An *empty result* (the table + * isn't found, has no lookup columns, or the app lacks metadata-read permission + * — a 401/403) yields an empty map; callers then send the body unchanged and + * Dataverse surfaces its own error at write time. Any *other failed request* + * (network / 5xx) propagates so the item fails loudly instead of silently + * skipping lookup translation. + */ +export async function resolveLookupFields( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + const set = normalizeEntitySet(entitySet); + let perCtx = cache.get(ctx); + if (!perCtx) { + perCtx = new Map(); + cache.set(ctx, perCtx); + } + const existing = perCtx.get(set); + if (existing) return await existing; + + const pending = buildLookupFieldMap(ctx, credentialType, set); + perCtx.set(set, pending); + try { + return await pending; + } catch (error) { + // Don't cache a failure — a later item should be able to retry. + perCtx.delete(set); + throw error; + } +} + +async function buildLookupFieldMap( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + try { + return await collectLookupFieldMap(ctx, credentialType, entitySet); + } catch (error) { + // Metadata read is best-effort: a 401/403 (no metadata-read permission) falls + // back to sending the body unchanged so Dataverse surfaces its own error. + // Other failures (network / 5xx) propagate. + if (isMetadataPermissionError(error)) return new Map(); + throw error; + } +} + +/** + * Whether a failed metadata request was purely a permission problem (401/403). + * `dataverseApiRequest` wraps failures in `NodeApiError` (status on `httpCode`) + * and keeps the original axios error under `cause`; check both. + */ +function isMetadataPermissionError(error: unknown): boolean { + const sources = [ + error as { httpCode?: unknown; statusCode?: unknown } | null, + (error as { cause?: unknown } | null)?.cause, + ]; + for (const src of sources) { + if (!src) continue; + const candidates = [ + (src as { httpCode?: unknown }).httpCode, + (src as { statusCode?: unknown }).statusCode, + (src as { response?: { status?: unknown } }).response?.status, + ]; + if (candidates.some((c) => c === 401 || c === 403 || c === '401' || c === '403')) { + return true; + } + } + return false; +} + +async function collectLookupFieldMap( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + const map: LookupFieldMap = new Map(); + + // 1. entity-set (plural) → table LogicalName. An unknown entity set yields no + // match; degrade to an empty map (the write itself surfaces the real error). + const logicalName = await resolveLogicalName(ctx, credentialType, entitySet); + if (!logicalName) return map; + + // 2. Many-to-one relationships — the lookup columns on this table. + const rels = await dataverseApiRequest( + ctx, + 'GET', + `/EntityDefinitions(LogicalName='${logicalName}')/ManyToOneRelationships`, + {}, + { + $select: 'ReferencingAttribute,ReferencingEntityNavigationPropertyName,ReferencedEntity', + }, + {}, + credentialType, + ); + const rows = (rels.value as RelationshipRow[] | undefined) ?? []; + if (rows.length === 0) return map; + + // 3. Referenced table logical names → entity-set names, in one batched call. + // Abstract targets (e.g. `owner`) aren't bindable themselves; resolve their + // concrete members instead so the documented bind paths validate. + const referenced = [ + ...new Set( + rows + .map((row) => row.ReferencedEntity) + .filter((name): name is string => !!name) + .flatMap((name) => ABSTRACT_LOOKUP_TARGETS[name.toLowerCase()] ?? [name]), + ), + ]; + const metadataByLogical = await resolveTableMetadataByLogicalNames( + ctx, + credentialType, + referenced, + ); + + for (const row of rows) { + const attribute = row.ReferencingAttribute?.toLowerCase(); + const navigationProperty = row.ReferencingEntityNavigationPropertyName; + const referencedEntity = row.ReferencedEntity; + if (!attribute || !navigationProperty || !referencedEntity) continue; + // Expand an abstract target (owner) into its concrete members (systemuser, + // team), all sharing the single navigation property. + const targets = ABSTRACT_LOOKUP_TARGETS[referencedEntity.toLowerCase()] ?? [referencedEntity]; + const candidates = map.get(attribute) ?? []; + for (const target of targets) { + const metadata = metadataByLogical.get(target); + if (!metadata) continue; + candidates.push({ + navigationProperty, + referencedEntity: target, + targetEntitySet: metadata.entitySetName, + tableType: metadata.tableType, + primaryIdAttribute: metadata.primaryIdAttribute, + }); + } + if (candidates.length > 0) map.set(attribute, candidates); + } + return map; +} + +async function resolveLogicalName( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + const response = await dataverseApiRequest( + ctx, + 'GET', + '/EntityDefinitions', + {}, + { + $select: 'LogicalName', + $filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`, + }, + {}, + credentialType, + ); + const value = response.value as Array<{ LogicalName?: string }> | undefined; + return value?.[0]?.LogicalName; +} + +/** + * Cheap pre-check: could `body` contain a value {@link applyLookupBindings} may + * need relationship metadata to translate or validate? Returns true for any + * non-`@odata.bind` key whose value is `null` (a possible disassociation) or a + * non-empty string (which could be a lookup reference — or a *mistyped* one, a + * plain name, that must still be validated). Numbers, booleans, empty strings, + * and keys already carrying `@odata.bind` are never candidates. + * + * This lets a write op skip {@link resolveLookupFields} for a body with no such + * values (numbers/booleans only, or `@odata.bind`-only). It errs toward + * resolving: a false positive costs only a per-table cached metadata lookup, + * whereas a false negative would forward a bad lookup value as a raw field and + * surface Dataverse's opaque 400. Metadata read is best-effort + * ({@link resolveLookupFields}), so this no longer forces metadata-read + * permission on a plain write. + */ +export function bodyHasLookupCandidates(body: IDataObject): boolean { + for (const [key, value] of Object.entries(body)) { + if (key.includes('@odata.bind')) continue; + if (value === null) return true; + if (typeof value === 'string' && value.trim() !== '') return true; + if (typeof value === 'object' && value !== null && 'id' in value) return true; + } + return false; +} + +/** + * Rewrite any lookup columns in `body` to their `@odata.bind` navigation-property + * form. Non-lookup fields and keys that already carry `@odata.bind` are passed + * through untouched. Throws a {@link NodeOperationError} for values that can't be + * bound unambiguously (e.g. a bare GUID for a polymorphic lookup). + */ +export function applyLookupBindings( + ctx: IExecuteFunctions, + itemIndex: number, + body: IDataObject, + lookupFields: LookupFieldMap, +): IDataObject { + const out: IDataObject = {}; + for (const [key, value] of Object.entries(body)) { + // Advanced users can supply a ready-made bind key; leave it alone. + if (key.includes('@odata.bind')) { + setSafeObjectProperty(out, key, value); + continue; + } + const candidates = lookupFields.get(key.toLowerCase()); + if (!candidates || candidates.length === 0) { + setSafeObjectProperty(out, key, value); + continue; + } + const binding = buildLookupBinding(ctx, itemIndex, key, value, candidates); + if (binding.disassociate) { + // Clear the relationship via the single-valued navigation property. + setSafeObjectProperty(out, binding.navigationProperty, null); + } else { + setSafeObjectProperty(out, `${binding.navigationProperty}@odata.bind`, binding.path); + } + } + return out; +} + +interface Binding { + navigationProperty: string; + path: string; + disassociate: boolean; +} + +function buildLookupBinding( + ctx: IExecuteFunctions, + itemIndex: number, + field: string, + value: unknown, + candidates: LookupCandidate[], +): Binding { + const single = candidates.length === 1 ? candidates[0] : undefined; + const targets = candidates.map((candidate) => candidate.targetEntitySet).join(', '); + + // null → disassociate. Only unambiguous for a single-target lookup. + if (value === null) { + if (!single) { + throw new NodeOperationError( + ctx.getNode(), + `Cannot clear the multi-table lookup "${field}" from a null value — it can point to ${targets}. Clear it via the specific navigation property instead.`, + { itemIndex }, + ); + } + return { navigationProperty: single.navigationProperty, path: '', disassociate: true }; + } + + if (typeof value === 'object' && 'id' in value) { + if (!single) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup "${field}" can point to multiple tables (${targets}). Provide a complete "/entityset(key)" reference.`, + { itemIndex }, + ); + } + const id = typeof value.id === 'string' ? value.id.trim() : ''; + if (!GUID_PATTERN.test(id)) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" needs a valid row GUID.`, + { + itemIndex, + }, + ); + } + if (!isElasticTable(single)) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" only accepts an object value for an elastic table target.`, + { itemIndex }, + ); + } + if (!('partitionId' in value)) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" targets an elastic table. Provide both "id" and "partitionId".`, + { itemIndex }, + ); + } + if (value.partitionId === null) { + return { + navigationProperty: single.navigationProperty, + path: `/${single.targetEntitySet}(${id})`, + disassociate: false, + }; + } + const partitionId = typeof value.partitionId === 'string' ? value.partitionId.trim() : ''; + if (!partitionId || !single.primaryIdAttribute) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" needs a non-empty partition ID and target primary key metadata.`, + { itemIndex }, + ); + } + if (hasUrlBreakingChars(partitionId)) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" partition ID must not contain URL delimiters, path separators, or control characters.`, + { itemIndex }, + ); + } + return { + navigationProperty: single.navigationProperty, + path: `/${single.targetEntitySet}(${single.primaryIdAttribute}=${id},partitionid='${partitionId.replace(/'/g, "''")}')`, + disassociate: false, + }; + } + + const raw = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + if (!raw) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" has an empty value. Provide a record GUID or a "/entityset(id)" reference.`, + { itemIndex }, + ); + } + + // Full reference form: "/contacts()" or "contacts()". The pattern + // already allows an optional leading slash, so it also rejects a malformed + // slash-prefixed value like "/contacts" (no key) instead of forwarding it. + if (ENTITYSET_PATH_PATTERN.test(raw)) { + const path = raw.startsWith('/') ? raw : `/${raw}`; + const targetEntitySet = path.slice(1, path.indexOf('(')); + // The reference's entity set must match one of the lookup's targets. Do NOT + // fall back to `single` here: a single-target lookup given a reference to the + // wrong table must fail node-side, not forward a mismatched path to Dataverse. + const match = candidates.find((c) => c.targetEntitySet === targetEntitySet); + if (!match) { + throw new NodeOperationError( + ctx.getNode(), + `The target "${targetEntitySet}" for lookup "${field}" does not match any related table (${targets}).`, + { itemIndex }, + ); + } + return { navigationProperty: match.navigationProperty, path, disassociate: false }; + } + + // Bare GUID form — needs a single, unambiguous target table. + if (GUID_PATTERN.test(raw)) { + if (!single) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup "${field}" can point to multiple tables (${targets}). Provide a "/entityset(${raw})" reference so the target table is unambiguous.`, + { itemIndex }, + ); + } + if (isElasticTable(single)) { + throw new NodeOperationError( + ctx.getNode(), + `Lookup "${field}" targets an elastic table. Provide { "id": "${raw}", "partitionId": "..." } or a complete "/entityset(key)" reference.`, + { itemIndex }, + ); + } + return { + navigationProperty: single.navigationProperty, + path: `/${single.targetEntitySet}(${raw})`, + disassociate: false, + }; + } + + throw new NodeOperationError( + ctx.getNode(), + `Lookup field "${field}" needs a record GUID or a "/entityset(id)" reference, got "${raw}".`, + { itemIndex }, + ); +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/metadata.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/metadata.ts new file mode 100644 index 00000000000..5709f8453e6 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/metadata.ts @@ -0,0 +1,115 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { dataverseApiRequest, type DataverseQuery } from '../GenericFunctions'; +import { normalizeEntitySet } from './shared'; + +export interface DataverseTableMetadata { + logicalName: string; + entitySetName: string; + primaryIdAttribute?: string; + tableType?: string; +} + +interface EntityDefinitionRow { + LogicalName?: string; + EntitySetName?: string; + PrimaryIdAttribute?: string; + TableType?: string; +} + +const entitySetCache = new WeakMap< + object, + Map> +>(); + +export function isElasticTable(metadata: Pick): boolean { + return metadata.tableType?.toLowerCase() === 'elastic'; +} + +export async function resolveTableMetadata( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + const normalizedEntitySet = normalizeEntitySet(entitySet); + let perContext = entitySetCache.get(ctx); + if (!perContext) { + perContext = new Map(); + entitySetCache.set(ctx, perContext); + } + + const existing = perContext.get(normalizedEntitySet); + if (existing) return await existing; + + const pending = fetchTableMetadata(ctx, credentialType, normalizedEntitySet); + perContext.set(normalizedEntitySet, pending); + try { + return await pending; + } catch (error) { + perContext.delete(normalizedEntitySet); + throw error; + } +} + +async function fetchTableMetadata( + ctx: IExecuteFunctions, + credentialType: string, + entitySet: string, +): Promise { + const response = await dataverseApiRequest( + ctx, + 'GET', + '/EntityDefinitions', + {}, + { + $select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType', + $filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`, + }, + {}, + credentialType, + ); + const row = (response.value as EntityDefinitionRow[] | undefined)?.[0]; + return row ? toTableMetadata(row) : undefined; +} + +export async function resolveTableMetadataByLogicalNames( + ctx: IExecuteFunctions, + credentialType: string, + logicalNames: string[], +): Promise> { + const result = new Map(); + const uniqueNames = [...new Set(logicalNames)]; + if (uniqueNames.length === 0) return result; + + const filter = uniqueNames + .map((name) => `LogicalName eq '${name.replace(/'/g, "''")}'`) + .join(' or '); + const response = await dataverseApiRequest( + ctx, + 'GET', + '/EntityDefinitions', + {}, + { + $select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType', + $filter: filter, + } as DataverseQuery, + {}, + credentialType, + ); + const rows = (response.value as EntityDefinitionRow[] | undefined) ?? []; + for (const row of rows) { + const metadata = toTableMetadata(row); + if (metadata) result.set(metadata.logicalName, metadata); + } + return result; +} + +function toTableMetadata(row: EntityDefinitionRow): DataverseTableMetadata | undefined { + if (!row.LogicalName || !row.EntitySetName) return undefined; + return { + logicalName: row.LogicalName, + entitySetName: row.EntitySetName, + primaryIdAttribute: row.PrimaryIdAttribute, + tableType: row.TableType, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/shared.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/shared.ts new file mode 100644 index 00000000000..3a48cd5a313 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/shared.ts @@ -0,0 +1,411 @@ +import type { IDataObject, IExecuteFunctions, IHttpRequestMethods } from 'n8n-workflow'; +import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow'; +import { + dataverseApiRequest, + dataverseApiRequestAllItems, + dataverseApiRequestWithResponse, + type DataverseHeaders, + type DataverseQuery, +} from '../GenericFunctions'; + +/** + * Names of OData system query options accepted by the Dataverse Web API in the + * shape this module supports. Mirrors the dv connector (Power Automate) param + * surface: `$select`, `$filter`, `$orderby`, `$expand`, `$top`. + */ +export type ODataOptionKey = 'select' | 'filter' | 'orderby' | 'expand' | 'top'; + +const OPTION_TO_QS: Record = { + select: '$select', + filter: '$filter', + orderby: '$orderby', + expand: '$expand', + top: '$top', +}; + +/** + * Translate an options bag from the node UI into a Dataverse query string. + * + * Empty strings, undefined, and null values are dropped so the URL stays clean + * and the server doesn't see stray `?$select=` parameters. Numeric values are + * preserved so `$top` works. + * + * ```ts + * buildODataQs({ select: 'name', filter: 'statecode eq 0' }) + * // → { $select: 'name', $filter: 'statecode eq 0' } + * ``` + */ +export function buildODataQs(options: Partial>): DataverseQuery { + const qs: DataverseQuery = {}; + for (const key of Object.keys(OPTION_TO_QS) as ODataOptionKey[]) { + const value = options[key]; + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + if (value.length === 0) continue; + qs[OPTION_TO_QS[key]] = value.join(','); + continue; + } + qs[OPTION_TO_QS[key]] = value as string | number; + } + return qs; +} + +/** + * Two-mode item input — matches the dv connector's "Row Item" dynamic field + * while staying ergonomic in n8n: + * + * - **JSON** (default): the user pastes / templates a record object. + * - **Fields**: the user adds rows of `{ name, value }` pairs via a + * `fixedCollection`. + * + * `value` strings are kept as-is so users can template lookup bindings + * (`accountid@odata.bind`) without us mangling them. + */ +export interface FieldsCollection { + field?: Array<{ name: string; value: unknown }>; +} + +export function parseItemInput(ctx: IExecuteFunctions, itemIndex: number): IDataObject { + const mode = (ctx.getNodeParameter('inputMode', itemIndex, 'json') as string) ?? 'json'; + + if (mode === 'fields') { + const raw = ctx.getNodeParameter('fieldsCollection', itemIndex, {}) as FieldsCollection; + const out: IDataObject = {}; + for (const entry of raw.field ?? []) { + if (!entry || !entry.name) continue; + setSafeObjectProperty(out, entry.name, entry.value); + } + return out; + } + + // JSON mode + const raw = ctx.getNodeParameter('fieldsJson', itemIndex, {}) as string | IDataObject; + if (raw === undefined || raw === null || raw === '') return {}; + if (typeof raw === 'object') return raw as IDataObject; + try { + const parsed = JSON.parse(raw as string); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new NodeOperationError( + ctx.getNode(), + `Parameter "fieldsJson" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, + { itemIndex }, + ); + } + return parsed as IDataObject; + } catch (err) { + // Already a NodeOperationError thrown by the validation above — re-throw as-is so the original itemIndex / message survive. + if (err instanceof NodeOperationError) throw err; + throw new NodeOperationError(ctx.getNode(), 'Parameter "fieldsJson" is not valid JSON', { + itemIndex, + }); + } +} + +/** + * Compose a `Prefer` header value. The dv connector's "Return Full Metadata" + * toggle maps to OData annotations; we also let callers ask for the created / + * updated row to be echoed back via `return=representation`. + */ +export interface PreferOptions { + returnRepresentation?: boolean; + includeFullMetadata?: boolean; + maxPageSize?: number; + extra?: string[]; +} + +export function buildPreferHeader(opts: PreferOptions): string | undefined { + const parts: string[] = []; + if (opts.returnRepresentation) parts.push('return=representation'); + if (opts.includeFullMetadata) parts.push('odata.include-annotations="*"'); + if (typeof opts.maxPageSize === 'number' && opts.maxPageSize > 0) { + parts.push(`odata.maxpagesize=${opts.maxPageSize}`); + } + if (opts.extra) parts.push(...opts.extra); + return parts.length ? parts.join(',') : undefined; +} + +/** + * Strip whitespace / trailing slashes from a user-entered entity set name + * (e.g. someone pasting "accounts/"). Dataverse rejects either of those + * variants with an unhelpful 404, so we normalize aggressively up-front. + */ +function resourceLocatorValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'object' && value !== null && 'value' in value) { + return typeof value.value === 'string' ? value.value : ''; + } + return ''; +} + +export function normalizeEntitySet(value: unknown): string { + return resourceLocatorValue(value) + .trim() + .replace(/^\/+|\/+$/g, ''); +} + +const ENTITY_SET_PATTERN = /^[a-z][a-z0-9_]*$/i; + +export function isValidEntitySet(value: unknown): boolean { + return ENTITY_SET_PATTERN.test(normalizeEntitySet(value)); +} + +/** + * Validate a table name before interpolating it into a request path. + * Dataverse entity-set names are identifiers, so path separators, URL + * delimiters, whitespace, and traversal segments are never valid here. + * + * @throws NodeOperationError naming the parameter. + */ +export function assertValidEntitySet( + ctx: IExecuteFunctions, + itemIndex: number, + entitySet: unknown, + paramName = 'entitySet', +): string { + const normalized = normalizeEntitySet(entitySet); + if (!isValidEntitySet(normalized)) { + throw new NodeOperationError( + ctx.getNode(), + `Parameter "${paramName}" must be a valid Dataverse table name`, + { itemIndex }, + ); + } + return normalized; +} + +/** + * Build the OData path segment for addressing a record. Supports two + * identifier shapes that the dv connector / Dataverse Web API both accept: + * + * - **GUID**: `/accounts(7c-..-...)` + * - **Alternate key**: `/accounts(accountnumber='ACC-001')` + * + * `recordId` is forwarded as-is — Dataverse parses both shapes natively, so we + * don't need to wrap quotes ourselves (callers must supply them when using + * alt-keys with string values). Callers validate the entity set with + * {@link assertValidEntitySet} and the identifier with {@link assertValidRecordId} + * or {@link assertValidAlternateKey}, so URL-breaking input never reaches here. + */ +export function buildRecordPath(entitySet: string, recordId: string): string { + const safeSet = normalizeEntitySet(entitySet); + const id = String(recordId).trim(); + return `/${safeSet}(${id})`; +} + +/** + * Validate a user-entered Row ID up front. n8n's `required: true` flag stops + * a completely empty field from being submitted, but a string like `" "` or + * an expression that resolves to `null`/`undefined` still slips through and + * produces a confusing `/accounts()` URL. + * + * @throws NodeOperationError with a clear, parameter-named message. + */ +export function assertNonEmptyRecordId( + ctx: IExecuteFunctions, + itemIndex: number, + recordId: unknown, + paramName = 'recordId', +): string { + const trimmed = resourceLocatorValue(recordId).trim(); + if (!trimmed) { + throw new NodeOperationError( + ctx.getNode(), + `Parameter "${paramName}" is required and must be a non-empty string`, + { itemIndex }, + ); + } + return trimmed; +} + +const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Chars that would truncate or redirect the request URL if interpolated raw. +const URL_BREAKING_CHARS = /[?#/\\]|[\x00-\x1f]/; + +export function hasUrlBreakingChars(value: string): boolean { + return URL_BREAKING_CHARS.test(value); +} + +/** + * Validate a Row ID before interpolating it into the request path. The field is + * a GUID by definition, so require that shape — a value carrying `?`/`#`/`/` + * (from a mapped field or expression) would otherwise silently truncate the URL + * and hit a different record than intended. + * + * @throws NodeOperationError naming the parameter. + */ +export function assertValidRecordId( + ctx: IExecuteFunctions, + itemIndex: number, + recordId: unknown, + paramName = 'recordId', +): string { + const id = assertNonEmptyRecordId(ctx, itemIndex, recordId, paramName); + if (!GUID_PATTERN.test(id)) { + throw new NodeOperationError( + ctx.getNode(), + `Parameter "${paramName}" must be a record GUID (e.g. 00000000-0000-0000-0000-000000000000)`, + { itemIndex }, + ); + } + return id; +} + +/** + * Validate an alternate-key predicate before interpolating it into the path. The + * predicate legitimately uses quotes and commas, so we don't constrain the + * syntax. URL delimiters, path separators, and ASCII control characters would + * be reinterpreted or stripped by the URL parser, so reject those. + * + * @throws NodeOperationError naming the parameter. + */ +export function assertValidAlternateKey( + ctx: IExecuteFunctions, + itemIndex: number, + alternateKey: unknown, + paramName = 'alternateKey', +): string { + const predicate = assertNonEmptyRecordId(ctx, itemIndex, alternateKey, paramName); + if (hasUrlBreakingChars(predicate)) { + throw new NodeOperationError( + ctx.getNode(), + `Parameter "${paramName}" must not contain URL delimiters, path separators, or control characters`, + { itemIndex }, + ); + } + return predicate; +} + +/** + * Reject an empty body for write operations (create / update / upsert). + * Dataverse silently 204s on an empty PATCH and 400s on an empty POST; either + * way the user wanted to send fields and didn't. Fail fast at the node level + * with a clearer message. + */ +export function assertNonEmptyBody( + ctx: IExecuteFunctions, + itemIndex: number, + body: IDataObject, + opName: string, +): IDataObject { + if (!body || Object.keys(body).length === 0) { + throw new NodeOperationError( + ctx.getNode(), + `${opName} requires at least one field. Provide a non-empty Row Item.`, + { itemIndex }, + ); + } + return body; +} + +/** Add a partition ID to the query string for supported read and delete requests. */ +export function applyPartitionIdToQuery(qs: IDataObject, partitionId?: unknown): IDataObject { + if (typeof partitionId === 'string' && partitionId.trim()) { + qs.partitionId = partitionId.trim(); + } + return qs; +} + +// --------------------------------------------------------------------------- +// Request plan + executor +// --------------------------------------------------------------------------- + +/** + * Declarative spec for a single Dataverse Web API call. Every op module fills + * one of these out and hands it to {@link executeRequest}; the executor takes + * care of the cross-cutting concerns that used to be re-implemented inline in + * each op (partition-id, Prefer header, single vs. paged dispatch). + * + * - `body`/`qs`/`extraHeaders` are forwarded verbatim. + * - `prefer` is converted to a `Prefer` header via {@link buildPreferHeader} + * and merged with the caller's `extraHeaders.Prefer` if both are present. + * - `options.partitionId` is folded into the query string for supported read + * and delete requests. + * - `options.returnFullMetadata` is folded into the Prefer header. + * + * For `paged: true`, the executor calls {@link dataverseApiRequestAllItems} + * and returns an array of rows; otherwise it does a single-shot + * {@link dataverseApiRequest} returning the raw response. + */ +export interface RequestPlan { + method: IHttpRequestMethods; + /** Web API path relative to `/api/data/v9.2` (e.g. `/accounts` or `/accounts()`). */ + path: string; + body?: IDataObject; + qs?: DataverseQuery; + extraHeaders?: DataverseHeaders; + prefer?: PreferOptions; + /** Raw options bag from the op's `Options` collection — supplies partitionId, returnFullMetadata. */ + options?: IDataObject; + /** When true, fetch all pages; when false (default), single request. */ + paged?: boolean; + /** Only used when `paged: true`. 0 = no limit. */ + limit?: number; + /** Return the write response with its Dataverse logical-session token. */ + returnSessionToken?: boolean; +} + +/** + * Execute a {@link RequestPlan}, folding partition-id, Return-Full-Metadata, + * and `Prefer` header construction into one place. Returns the raw response + * (single-shot) or the flat list of rows (paged). + */ +export async function executeRequest( + ctx: IExecuteFunctions, + credentialType: string, + plan: RequestPlan, +): Promise { + const qs: DataverseQuery = { ...(plan.qs ?? {}) }; + const options = plan.options ?? {}; + applyPartitionIdToQuery(qs as IDataObject, options.partitionId); + + const prefer = buildPreferHeader({ + ...(plan.prefer ?? {}), + includeFullMetadata: plan.prefer?.includeFullMetadata ?? Boolean(options.returnFullMetadata), + }); + const headers: DataverseHeaders = { ...(plan.extraHeaders ?? {}) }; + if (prefer) headers.Prefer = prefer; + if (typeof options.sessionToken === 'string' && options.sessionToken.trim()) { + headers['MSCRM.SessionToken'] = options.sessionToken.trim(); + } + + if (plan.paged) { + return await dataverseApiRequestAllItems( + ctx, + plan.method, + plan.path, + qs, + plan.limit ?? 0, + credentialType, + headers, + ); + } + + if (plan.returnSessionToken) { + const response = await dataverseApiRequestWithResponse( + ctx, + plan.method, + plan.path, + plan.body ?? {}, + qs, + headers, + credentialType, + ); + const sessionToken = Object.entries(response.headers).find( + ([name]) => name.toLowerCase() === 'x-ms-session-token', + )?.[1]; + return { + data: response.body, + ...(typeof sessionToken === 'string' ? { sessionToken } : {}), + }; + } + + return await dataverseApiRequest( + ctx, + plan.method, + plan.path, + plan.body ?? {}, + qs, + headers, + credentialType, + ); +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/sharedProperties.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/sharedProperties.ts new file mode 100644 index 00000000000..019f34f5886 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/sharedProperties.ts @@ -0,0 +1,254 @@ +import type { IDisplayOptions, INodeProperties } from 'n8n-workflow'; + +/** + * Property descriptors that are reused by more than one operation. They live in + * their own module so each op file stays a tight description of its op-only + * shape (item input mode, partition id...). The `displayOptions` are built + * with the caller-supplied list of operation values so the same property can + * be opted in by several ops. + */ + +/** + * Build the standard `displayOptions.show` block scoping a property or + * collection to one or more record-level operations. Replaces the + * `{ show: { resource: ['row'], operation: ops } }` boilerplate that was + * previously inlined in every shared property factory. + */ +export function forOperation(operations: string[]): IDisplayOptions { + return { show: { resource: ['row'], operation: operations } }; +} + +/** Plural Web API name of the table (e.g. `accounts`). */ +export function commonEntitySetProperty(operations: string[]): INodeProperties { + return { + displayName: 'Table Name or ID', + name: 'entitySet', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The Dataverse table to operate on', + displayOptions: forOperation(operations), + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchEntitySets', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. accounts', + }, + ], + }; +} + +/** GUID of the record being targeted. */ +export function commonRecordIdProperty(operations: string[]): INodeProperties { + return { + displayName: 'Row ID', + name: 'recordId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The Dataverse row to operate on', + displayOptions: forOperation(operations), + typeOptions: { + loadOptionsDependsOn: ['entitySet.value'], + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchRows', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '00000000-0000-0000-0000-000000000000', + }, + ], + }; +} + +/** + * The `Row Item` payload. Exposes a `JSON ⇄ Fields` mode toggle so power users + * can paste an object and beginners can use a key/value collection — same UX + * as the dv connector's dynamic item builder, just spelled in n8n primitives. + */ +export function commonRowItemProperties(operations: string[]): INodeProperties[] { + return [ + { + displayName: 'Input Mode', + name: 'inputMode', + type: 'options', + noDataExpression: true, + default: 'json', + options: [ + { name: 'JSON', value: 'json', description: 'Paste a full record object' }, + { + name: 'Fields (Key / Value Pairs)', + value: 'fields', + description: 'Build the row one field at a time', + }, + ], + displayOptions: forOperation(operations), + }, + { + displayName: 'Row Item (JSON)', + name: 'fieldsJson', + type: 'json', + default: '{}', + required: true, + description: 'Row to send to Dataverse, as a JSON object of column logical name to value', + displayOptions: { + show: { ...forOperation(operations).show, inputMode: ['json'] }, + }, + }, + { + displayName: 'Fields', + name: 'fieldsCollection', + type: 'fixedCollection', + default: {}, + placeholder: 'Add Field', + typeOptions: { multipleValues: true, sortable: true }, + displayOptions: { + show: { ...forOperation(operations).show, inputMode: ['fields'] }, + }, + options: [ + { + displayName: 'Field', + name: 'field', + values: [ + { + displayName: 'Field Name or ID', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWriteColumns', + loadOptionsDependsOn: ['entitySet.value', 'operation'], + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set on the column', + }, + ], + }, + ], + }, + ]; +} + +/** Optional `Return Full Metadata` toggle (maps to `Prefer: odata.include-annotations="*"`). */ +export function commonReturnFullMetadataOption(): INodeProperties { + return { + displayName: 'Return Full Metadata', + name: 'returnFullMetadata', + type: 'boolean', + default: false, + description: + 'Whether to ask Dataverse to include OData annotations (odata.include-annotations="*")', + }; +} + +/** Optional partition ID for supported elastic-table read and delete requests. */ +export function commonPartitionIdOption(): INodeProperties { + return { + displayName: 'Partition ID', + name: 'partitionId', + type: 'string', + default: '', + placeholder: 'partition-key-value', + description: 'Partition key for an elastic table read or delete request', + }; +} + +/** Logical-session token used for consistent reads after an elastic-table write. */ +export function commonSessionTokenOption(): INodeProperties { + return { + displayName: 'Session Token', + name: 'sessionToken', + // eslint-disable-next-line n8n-nodes-base/node-param-type-options-password-missing -- sessionToken is a consistency token, not a password + type: 'string', + default: '', + description: 'Token from an earlier elastic-table write in the same logical session', + }; +} + +/** Include the Dataverse logical-session token in a write result. */ +export function commonReturnSessionTokenOption(): INodeProperties { + return { + displayName: 'Return Session Token', + name: 'returnSessionToken', + type: 'boolean', + default: false, + description: 'Whether to return the token used for consistent reads after this write', + }; +} + +/** `$select` — multi-select of column logical names. */ +export function commonSelectOption(): INodeProperties { + return { + displayName: 'Select Column Names or IDs', + name: 'select', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getReadColumns', + loadOptionsDependsOn: ['entitySet.value'], + }, + default: [], + description: + 'Choose from the list, or specify IDs using an expression', + }; +} + +/** `$expand` — comma-separated navigation properties (lookup / relationship). */ +export function commonExpandOption(): INodeProperties { + return { + displayName: 'Expand Query', + name: 'expand', + type: 'string', + default: '', + placeholder: 'primarycontactid($select=fullname)', + description: 'OData $expand expression to include related rows', + }; +} + +/** + * Wrap one or more option fields into the standard top-level "Options" + * collection that every operation exposes. Removes the boilerplate where each + * op file re-declared a 7-line `type: 'collection'` block with the same + * placeholder, default, and displayOptions. + */ +export function buildOptionsCollection( + opValue: string, + items: INodeProperties[], + overrides: { name?: string; displayName?: string } = {}, +): INodeProperties { + return { + displayName: overrides.displayName ?? 'Options', + name: overrides.name ?? `${opValue}Options`, + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: forOperation([opValue]), + options: items, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/types.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/types.ts new file mode 100644 index 00000000000..3a5323004a3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/types.ts @@ -0,0 +1,42 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; + +/** + * Single record-operation definition. The node composes UI from the registry + * of these and dispatches `execute` by `value`. Keeping each op self-contained + * makes it easy to add or remove actions without touching `Dataverse.node.ts`. + */ +export interface OperationDefinition { + /** The dropdown label shown in the n8n editor (e.g. "Add a new row"). */ + displayName: string; + /** Internal id used by `getNodeParameter('operation')`. */ + value: string; + /** Short description displayed under the dropdown entry. */ + description: string; + /** The action label that appears in n8n's "Action" column. */ + action: string; + /** UI fields specific to this operation (entitySet, recordId, options...). */ + properties: INodeProperties[]; + /** Run the op for a single input item. Receives the per-item context and the credential type. */ + execute( + ctx: IExecuteFunctions, + itemIndex: number, + credentialType: string, + ): Promise; +} + +/** + * The `Operation` dropdown option entry generated from a definition. + */ +export function toDropdownOption(def: OperationDefinition): INodePropertyOptions { + return { + name: def.displayName, + value: def.value, + description: def.description, + action: def.action, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/updateRow.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/updateRow.ts new file mode 100644 index 00000000000..c9f15d8215a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/updateRow.ts @@ -0,0 +1,77 @@ +import type { IDataObject } from 'n8n-workflow'; +import type { OperationDefinition } from './types'; +import { + applyLookupBindings, + bodyHasLookupCandidates, + EMPTY_LOOKUP_FIELDS, + resolveLookupFields, +} from './lookups'; +import { + assertNonEmptyBody, + assertValidEntitySet, + assertValidRecordId, + buildRecordPath, + executeRequest, + parseItemInput, +} from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonRecordIdProperty, + commonReturnFullMetadataOption, + commonReturnSessionTokenOption, + commonRowItemProperties, + commonSessionTokenOption, +} from './sharedProperties'; + +/** + * dv connector — "Update a row" (`UpdateRecord`). + * + * `PATCH /{entitySet}({recordId})` with `If-Match: *` so the server returns + * `412 Precondition Failed` if the row doesn't exist (this is what makes it + * an Update rather than an Upsert — see {@link upsertRow} for the upsert + * variant). `Prefer: return=representation` echoes the post-update row. + */ +export const updateRow: OperationDefinition = { + displayName: 'Update', + value: 'update', + description: 'Modify any selected row in a Microsoft Dataverse table', + action: 'Update a row', + properties: [ + commonEntitySetProperty(['update']), + { + ...commonRecordIdProperty(['update']), + description: + 'GUID of the row to update. For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.', + }, + ...commonRowItemProperties(['update']), + buildOptionsCollection('update', [ + commonReturnFullMetadataOption(), + commonSessionTokenOption(), + commonReturnSessionTokenOption(), + ]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i)); + // Validate before resolving lookup metadata so an empty Row Item fails fast + // without spending metadata requests. Lookup metadata is only resolved when the + // body actually carries a lookup-style value, so a plain write stays a single + // request and needs no metadata-read permission. + const rawBody = assertNonEmptyBody(ctx, i, parseItemInput(ctx, i), 'Update'); + const lookupFields = bodyHasLookupCandidates(rawBody) + ? await resolveLookupFields(ctx, credentialType, entitySet) + : EMPTY_LOOKUP_FIELDS; + const body = applyLookupBindings(ctx, i, rawBody, lookupFields); + const options = ctx.getNodeParameter('updateOptions', i, {}) as IDataObject; + return await executeRequest(ctx, credentialType, { + method: 'PATCH', + path: buildRecordPath(entitySet, recordId), + body, + options, + prefer: { returnRepresentation: true }, + extraHeaders: { 'If-Match': '*' }, + returnSessionToken: Boolean(options.returnSessionToken), + }); + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/operations/upsertRow.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/upsertRow.ts new file mode 100644 index 00000000000..fb0f5cb304b --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/operations/upsertRow.ts @@ -0,0 +1,175 @@ +import type { IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { type DataverseHeaders } from '../GenericFunctions'; +import { + applyLookupBindings, + bodyHasLookupCandidates, + EMPTY_LOOKUP_FIELDS, + resolveLookupFields, +} from './lookups'; +import { isElasticTable, resolveTableMetadata } from './metadata'; +import { + assertNonEmptyBody, + assertValidAlternateKey, + assertValidEntitySet, + assertValidRecordId, + buildRecordPath, + executeRequest, + parseItemInput, +} from './shared'; +import { + buildOptionsCollection, + commonEntitySetProperty, + commonRecordIdProperty, + commonReturnFullMetadataOption, + commonReturnSessionTokenOption, + commonRowItemProperties, + commonSessionTokenOption, + forOperation, +} from './sharedProperties'; +import type { OperationDefinition } from './types'; + +/** + * dv connector — "Upsert a row" (`PATCH /{entitySet}({recordId})`). + * + * Dataverse's Web API treats PATCH-by-id as an upsert by default: if the row + * exists it's updated, otherwise it's inserted with the supplied GUID. The + * dv connector's "Upsert a row" action wraps this same behavior. + * + * We expose two extra dimensions vs. the dv connector for flexibility: + * - **Identifier Type**: GUID (default) *or* an alternate-key predicate + * (e.g. `accountnumber='ACC-001'`) — both are valid Dataverse PATCH targets. + * - **Behavior**: + * - `upsert` (default) — no precondition headers + * - `updateOnly` — adds `If-Match: *` (server returns 404 if row missing) + * - `createOnly` — adds `If-None-Match: *` (server returns 412 if row exists) + */ +export const upsertRow: OperationDefinition = { + displayName: 'Create or Update', + value: 'upsert', + description: 'Create a new record, or update the current one if it already exists (upsert)', + action: 'Create or update a row', + properties: [ + commonEntitySetProperty(['upsert']), + { + displayName: 'Identifier Type', + name: 'identifierType', + type: 'options', + noDataExpression: true, + default: 'guid', + displayOptions: forOperation(['upsert']), + options: [ + { name: 'Row ID (GUID)', value: 'guid', description: 'Address the row by its primary key' }, + { + name: 'Alternate Key', + value: 'alternateKey', + description: 'Address the row by a configured alternate key', + }, + ], + }, + { + ...commonRecordIdProperty(['upsert']), + description: + 'GUID of the row to upsert. Dataverse will create the row with this GUID if it does not exist yet. For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.', + displayOptions: { + show: { ...forOperation(['upsert']).show, identifierType: ['guid'] }, + }, + }, + { + displayName: 'Alternate Key Predicate', + name: 'alternateKey', + type: 'string', + default: '', + required: true, + placeholder: "accountnumber='ACC-001'", + description: + "OData alternate-key predicate, e.g. accountnumber='ACC-001'. For a partitioned elastic table, include its primary key and partitionid.", + displayOptions: { + show: { ...forOperation(['upsert']).show, identifierType: ['alternateKey'] }, + }, + }, + ...commonRowItemProperties(['upsert']), + buildOptionsCollection('upsert', [ + { + displayName: 'Behavior', + name: 'behavior', + type: 'options', + default: 'upsert', + options: [ + { + name: 'Create or Update', + value: 'upsert', + description: + 'Create a new record, or update the current one if it already exists (upsert)', + }, + { + name: 'Update Only', + value: 'updateOnly', + description: 'Send If-Match: * — fail if row does not exist', + }, + { + name: 'Create Only', + value: 'createOnly', + description: 'Send If-None-Match: * — fail if row exists', + }, + ], + }, + { + displayName: 'Allow Elastic Table Replacement', + name: 'allowElasticTableReplacement', + type: 'boolean', + default: false, + description: + 'Whether to allow Create or Update to replace an existing elastic-table row. Values omitted from the Row Item are removed.', + }, + commonReturnFullMetadataOption(), + commonSessionTokenOption(), + commonReturnSessionTokenOption(), + ]), + ], + async execute(ctx, i, credentialType) { + const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i)); + const identifierType = ctx.getNodeParameter('identifierType', i, 'guid') as string; + const identifier = + identifierType === 'alternateKey' + ? assertValidAlternateKey(ctx, i, ctx.getNodeParameter('alternateKey', i), 'alternateKey') + : assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i)); + + const rawBody = parseItemInput(ctx, i); + // Validate before resolving lookup metadata so an empty Row Item fails fast + // without spending metadata requests. Lookup metadata is only resolved when the + // body actually carries a lookup-style value, so a plain write stays a single + // request and needs no metadata-read permission. + assertNonEmptyBody(ctx, i, rawBody, 'Create or Update'); + const lookupFields = bodyHasLookupCandidates(rawBody) + ? await resolveLookupFields(ctx, credentialType, entitySet) + : EMPTY_LOOKUP_FIELDS; + const body = applyLookupBindings(ctx, i, rawBody, lookupFields); + const options = ctx.getNodeParameter('upsertOptions', i, {}) as IDataObject; + const behavior = (options.behavior as string) ?? 'upsert'; + if (behavior === 'upsert' && !options.allowElasticTableReplacement) { + const metadata = await resolveTableMetadata(ctx, credentialType, entitySet); + if (metadata && isElasticTable(metadata)) { + throw new NodeOperationError( + ctx.getNode(), + 'Create or Update replaces the complete row for an elastic table. Enable "Allow Elastic Table Replacement" only when the Row Item contains every value to keep.', + { itemIndex: i }, + ); + } + } + const extraHeaders: DataverseHeaders = {}; + if (behavior === 'updateOnly') extraHeaders['If-Match'] = '*'; + if (behavior === 'createOnly') extraHeaders['If-None-Match'] = '*'; + + return await executeRequest(ctx, credentialType, { + method: 'PATCH', + path: buildRecordPath(entitySet, identifier), + body, + options, + prefer: { returnRepresentation: true }, + extraHeaders, + returnSessionToken: Boolean(options.returnSessionToken), + }); + }, +}; diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/GenericFunctions.test.ts new file mode 100644 index 00000000000..84a10076667 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/GenericFunctions.test.ts @@ -0,0 +1,567 @@ +import { sleep } from '@n8n/utils/sleep'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { + dataverseApiRequest, + dataverseApiRequestAllItems, + dataverseApiRequestWithResponse, +} from '../GenericFunctions'; + +// Neutralize the retry back-off so tests don't actually wait. +vi.mock('@n8n/utils/sleep', () => ({ sleep: vi.fn().mockResolvedValue(undefined) })); + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; +const BASE_URL = 'https://org.crm.dynamics.com'; +const API_PATH = '/api/data/v9.2'; + +describe('Microsoft Dataverse GenericFunctions', () => { + let ctx: ReturnType>; + let request: ReturnType; + + const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + ctx.getCredentials.mockResolvedValue({ environmentUrl: BASE_URL }); + request = vi.fn(); + ctx.helpers.httpRequestWithAuthentication = request as never; + }); + + describe('dataverseApiRequest', () => { + it('builds the URL, sets versioned User-Agent, and returns the response', async () => { + request.mockResolvedValue({ value: 'ok' }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'ok' }); + expect(request).toHaveBeenCalledTimes(1); + const [credType, options] = request.mock.calls[0]; + expect(credType).toBe(CREDENTIAL_TYPE); + expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`); + expect(options.json).toBe(true); + expect(options.headers).toMatchObject({ + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + 'User-Agent': 'n8n-nodes-base.microsoftDataverse/1.0', + 'Content-Type': 'application/json; charset=utf-8', + }); + }); + + it('omits the body when it is empty', async () => { + request.mockResolvedValue({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options).not.toHaveProperty('body'); + }); + + it('sends the body when provided', async () => { + request.mockResolvedValue({}); + + await dataverseApiRequest( + ctx, + 'POST', + '/accounts', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ); + + const [, options] = request.mock.calls[0]; + expect(options.body).toEqual({ name: 'Acme' }); + }); + + it('strips a trailing slash from the environment URL', async () => { + ctx.getCredentials.mockResolvedValue({ environmentUrl: `${BASE_URL}/` }); + request.mockResolvedValue({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`); + }); + + it('strips multiple trailing slashes from the environment URL', async () => { + ctx.getCredentials.mockResolvedValue({ environmentUrl: `${BASE_URL}//` }); + request.mockResolvedValue({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`); + }); + + it('wraps a failure in NodeApiError', async () => { + request.mockRejectedValue({ statusCode: 400, message: 'Bad Request' }); + + await expect( + dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + }); + + it('does not retry a non-transient 400', async () => { + request.mockRejectedValue({ statusCode: 400 }); + + await expect( + dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('retries a transient 429 then succeeds', async () => { + request + .mockRejectedValueOnce({ statusCode: 429 }) + .mockResolvedValueOnce({ value: 'recovered' }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'recovered' }); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('honors the Retry-After header over computed back-off', async () => { + request + .mockRejectedValueOnce({ + httpCode: '429', + cause: { response: { headers: { 'retry-after': '2' } } }, + }) + .mockResolvedValueOnce({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + expect(sleep).toHaveBeenCalledWith(2000); + }); + + it('honors a capitalized Retry-After header', async () => { + request + .mockRejectedValueOnce({ + httpCode: '429', + cause: { response: { headers: { 'Retry-After': '3' } } }, + }) + .mockResolvedValueOnce({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + expect(sleep).toHaveBeenCalledWith(3000); + }); + + it('honors a Retry-After HTTP-date over computed back-off', async () => { + const twoSecondsFromNow = new Date(Date.now() + 2000).toUTCString(); + request + .mockRejectedValueOnce({ + httpCode: '429', + cause: { response: { headers: { 'retry-after': twoSecondsFromNow } } }, + }) + .mockResolvedValueOnce({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + expect(sleep).toHaveBeenCalledTimes(1); + const [delay] = (sleep as unknown as ReturnType).mock.calls[0]; + // Allow a little slack for the clock advancing between throw and read. + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(2000); + }); + + it('caps a large Retry-After at the maximum honored delay', async () => { + request + .mockRejectedValueOnce({ + httpCode: '429', + cause: { response: { headers: { 'retry-after': '600' } } }, + }) + .mockResolvedValueOnce({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + expect(sleep).toHaveBeenCalledWith(120_000); + }); + + it('retries a transient network error (ECONNRESET) then succeeds', async () => { + request.mockRejectedValueOnce({ code: 'ECONNRESET' }).mockResolvedValueOnce({ value: 1 }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 1 }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('retries a bare "socket hang up" message with no error code', async () => { + request + .mockRejectedValueOnce({ message: 'socket hang up' }) + .mockResolvedValueOnce({ value: 1 }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 1 }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('retries a socket error nested under error.cause.code', async () => { + request + .mockRejectedValueOnce({ cause: { code: 'ETIMEDOUT' } }) + .mockResolvedValueOnce({ value: 1 }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 1 }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('retries a transient 504 then succeeds', async () => { + request.mockRejectedValueOnce({ statusCode: 504 }).mockResolvedValueOnce({ value: 'ok' }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'ok' }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('retries a client-side request timeout (ECONNABORTED) then succeeds', async () => { + request.mockRejectedValueOnce({ code: 'ECONNABORTED' }).mockResolvedValueOnce({ value: 1 }); + + const result = await dataverseApiRequest( + ctx, + 'GET', + '/accounts', + {}, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 1 }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('gives up after the maximum number of retries on a persistent 503', async () => { + request.mockRejectedValue({ statusCode: 503 }); + + await expect( + dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + + // 1 initial + 3 retries = 4 dispatches, 3 back-off sleeps. + expect(request).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledTimes(3); + }); + + it('does not retry a POST after an ambiguous 504 (avoids duplicate rows)', async () => { + request.mockRejectedValue({ statusCode: 504 }); + + await expect( + dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not retry a POST after an ambiguous 503', async () => { + request.mockRejectedValue({ statusCode: 503 }); + + await expect( + dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not retry a POST after an ambiguous socket error (ECONNRESET)', async () => { + request.mockRejectedValue({ code: 'ECONNRESET' }); + + await expect( + dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not retry a POST after a client-side timeout (may have reached the server)', async () => { + request.mockRejectedValue({ code: 'ECONNABORTED' }); + + await expect( + dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('retries a POST after a 429 (rejected before processing)', async () => { + request + .mockRejectedValueOnce({ statusCode: 429 }) + .mockResolvedValueOnce({ value: 'created' }); + + const result = await dataverseApiRequest( + ctx, + 'POST', + '/accounts', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'created' }); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('retries a POST after a connection-establishment error (ECONNREFUSED)', async () => { + request + .mockRejectedValueOnce({ code: 'ECONNREFUSED' }) + .mockResolvedValueOnce({ value: 'created' }); + + const result = await dataverseApiRequest( + ctx, + 'POST', + '/accounts', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'created' }); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('does not retry a PATCH after an ambiguous 504 (avoids re-running plugins)', async () => { + request.mockRejectedValue({ statusCode: 504 }); + + await expect( + dataverseApiRequest( + ctx, + 'PATCH', + '/accounts(1)', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ), + ).rejects.toThrow(NodeApiError); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('retries a PATCH after a 429 (rejected before processing)', async () => { + request.mockRejectedValueOnce({ statusCode: 429 }).mockResolvedValueOnce({ value: 'ok' }); + + const result = await dataverseApiRequest( + ctx, + 'PATCH', + '/accounts(1)', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual({ value: 'ok' }); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('sets a request timeout and a version-derived User-Agent', async () => { + ctx.getNode.mockReturnValue({ ...node, typeVersion: 2 }); + request.mockResolvedValue({}); + + await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options.timeout).toBe(60_000); + expect(options.headers['User-Agent']).toBe('n8n-nodes-base.microsoftDataverse/2.0'); + }); + }); + + describe('dataverseApiRequestWithResponse', () => { + it('returns the full HTTP response when requested', async () => { + const response = { + body: { accountid: 'account-id' }, + headers: { 'x-ms-session-token': 'session-token' }, + statusCode: 200, + }; + request.mockResolvedValue(response); + + const result = await dataverseApiRequestWithResponse( + ctx, + 'PATCH', + '/accounts(account-id)', + { name: 'Acme' }, + {}, + {}, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual(response); + expect(request.mock.calls[0][1]).toMatchObject({ returnFullResponse: true }); + }); + }); + + describe('dataverseApiRequestAllItems', () => { + it('follows @odata.nextLink and aggregates every page', async () => { + request + .mockResolvedValueOnce({ + value: [{ id: 1 }], + '@odata.nextLink': 'https://org.crm.dynamics.com/next', + }) + .mockResolvedValueOnce({ value: [{ id: 2 }] }); + + const result = await dataverseApiRequestAllItems( + ctx, + 'GET', + '/accounts', + {}, + 0, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + expect(request).toHaveBeenCalledTimes(2); + const [, secondOptions] = request.mock.calls[1]; + expect(secondOptions.url).toBe('https://org.crm.dynamics.com/next'); + // nextLink already encodes the query string, so qs must not be resent. + expect(secondOptions.qs).toBeUndefined(); + }); + + it('stops once the requested limit is reached', async () => { + request.mockResolvedValueOnce({ + value: [{ id: 1 }, { id: 2 }, { id: 3 }], + '@odata.nextLink': 'https://org.crm.dynamics.com/next', + }); + + const result = await dataverseApiRequestAllItems( + ctx, + 'GET', + '/accounts', + {}, + 1, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual([{ id: 1 }]); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('preserves a caller Prefer header', async () => { + request.mockResolvedValueOnce({ value: [] }); + + await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE, { + Prefer: 'return=representation', + }); + + const [, options] = request.mock.calls[0]; + expect(options.headers.Prefer).toBe('return=representation'); + }); + + it('does not set Prefer when the caller does not provide one', async () => { + request.mockResolvedValueOnce({ value: [] }); + + await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options.headers.Prefer).toBeUndefined(); + }); + + it('forwards $top without a conflicting page-size preference', async () => { + request.mockResolvedValueOnce({ value: [] }); + + await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', { $top: 250 }, 0, CREDENTIAL_TYPE); + + const [, options] = request.mock.calls[0]; + expect(options.qs).toEqual({ $top: 250 }); + expect(options.headers.Prefer).toBeUndefined(); + }); + + it('returns an empty array when a page has no value field', async () => { + request.mockResolvedValueOnce({}); + + const result = await dataverseApiRequestAllItems( + ctx, + 'GET', + '/accounts', + {}, + 0, + CREDENTIAL_TYPE, + ); + + expect(result).toEqual([]); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('wraps a paging failure in NodeApiError', async () => { + request.mockRejectedValue({ statusCode: 400, message: 'Bad Request' }); + + await expect( + dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE), + ).rejects.toThrow(NodeApiError); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/MicrosoftDataverse.node.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/MicrosoftDataverse.node.test.ts new file mode 100644 index 00000000000..b3ce052e397 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/MicrosoftDataverse.node.test.ts @@ -0,0 +1,217 @@ +import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { + getEntitySets, + getReadColumns, + getWriteColumns, + searchEntitySets, + searchRows, +} from '../loadOptions'; +import { MicrosoftDataverse } from '../MicrosoftDataverse.node'; +import { createRow } from '../operations/createRow'; +import { getManyRows } from '../operations/getManyRows'; + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; + +/** + * These tests exercise the node's real routing: `resolveOperation` picks the + * op module from the shared registry, and the node wraps its output. Only the + * leaf `execute` of each op module is stubbed (via `vi.spyOn`), so aliasing and + * error handling run against production code. + */ +describe('MicrosoftDataverse Node', () => { + let ctx: ReturnType>; + + const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + beforeEach(() => { + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + ctx.continueOnFail.mockReturnValue(false); + + // Minimal faithful implementations so we can assert on wrapping/paired items. + ctx.helpers.returnJsonArray.mockImplementation((data) => { + const arr = Array.isArray(data) ? data : [data]; + return arr.map((json) => ({ json })) as INodeExecutionData[]; + }); + ctx.helpers.constructExecutionMetaData.mockImplementation( + (data, { itemData }) => + (data as INodeExecutionData[]).map((d) => ({ + ...d, + pairedItem: itemData, + })) as unknown as ReturnType, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses searchable resource locators for every table and row ID property', () => { + const instance = new MicrosoftDataverse(); + const tableProperties = instance.description.properties.filter( + (property) => property.name === 'entitySet', + ); + const rowProperties = instance.description.properties.filter( + (property) => property.name === 'recordId', + ); + + expect(tableProperties).toHaveLength(6); + expect(tableProperties.every((property) => property.type === 'resourceLocator')).toBe(true); + expect( + tableProperties.every( + (property) => property.modes?.[0]?.typeOptions?.searchListMethod === 'searchEntitySets', + ), + ).toBe(true); + expect(rowProperties).toHaveLength(4); + expect(rowProperties.every((property) => property.type === 'resourceLocator')).toBe(true); + expect( + rowProperties.every( + (property) => property.modes?.[0]?.typeOptions?.searchListMethod === 'searchRows', + ), + ).toBe(true); + expect(instance.methods.listSearch).toEqual({ + searchEntitySets, + searchRows, + }); + expect(instance.methods.loadOptions).toEqual({ + getEntitySets, + getReadColumns, + getWriteColumns, + }); + }); + + it('lists operations alphabetically by name, matching the n8n catalog convention', () => { + const instance = new MicrosoftDataverse(); + const operation = instance.description.properties.find( + (property) => property.name === 'operation', + ); + const names = (operation?.options ?? []).map((option) => (option as { name: string }).name); + + expect(names).toEqual(['Create', 'Create or Update', 'Delete', 'Get', 'Get Many', 'Update']); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); + + it('labels Update and Upsert request settings as Options', () => { + const instance = new MicrosoftDataverse(); + const optionCollections = instance.description.properties.filter((property) => + ['updateOptions', 'upsertOptions'].includes(property.name), + ); + + expect(optionCollections).toHaveLength(2); + expect(optionCollections.every((property) => property.displayName === 'Options')).toBe(true); + expect( + optionCollections.every( + (property) => !property.options?.some((option) => option.name === 'partitionId'), + ), + ).toBe(true); + }); + + it('routes an operation to the matching op module and wraps the result', async () => { + ctx.getInputData.mockReturnValue([{ json: { in: 1 } }]); + ctx.getNodeParameter.mockReturnValue('create'); + const executeSpy = vi.spyOn(createRow, 'execute').mockResolvedValue({ id: 'row-1' }); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(executeSpy).toHaveBeenCalledWith(ctx, 0, CREDENTIAL_TYPE); + expect(result).toEqual([[{ json: { id: 'row-1' }, pairedItem: { item: 0 } }]]); + }); + + it('flattens an array result from an op module into multiple items', async () => { + ctx.getInputData.mockReturnValue([{ json: {} }]); + ctx.getNodeParameter.mockReturnValue('getAll'); + vi.spyOn(getManyRows, 'execute').mockResolvedValue([{ id: 'a' }, { id: 'b' }]); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(result[0]).toHaveLength(2); + expect(result[0].map((r) => r.json)).toEqual([{ id: 'a' }, { id: 'b' }]); + }); + + it('throws NodeOperationError for an unsupported operation', async () => { + ctx.getInputData.mockReturnValue([{ json: {} }]); + ctx.getNodeParameter.mockReturnValue('does-not-exist'); + + await expect(MicrosoftDataverse.prototype.execute.call(ctx)).rejects.toThrow( + NodeOperationError, + ); + await expect(MicrosoftDataverse.prototype.execute.call(ctx)).rejects.toThrow( + /Unsupported operation "does-not-exist"/, + ); + }); + + it('collects the error instead of throwing when continueOnFail is on', async () => { + ctx.getInputData.mockReturnValue([{ json: {} }]); + ctx.getNodeParameter.mockReturnValue('create'); + ctx.continueOnFail.mockReturnValue(true); + vi.spyOn(createRow, 'execute').mockRejectedValue(new Error('boom')); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(result).toEqual([[{ json: { error: 'boom' }, pairedItem: { item: 0 } }]]); + }); + + it('processes every input item and preserves the paired item index', async () => { + ctx.getInputData.mockReturnValue([{ json: { i: 0 } }, { json: { i: 1 } }]); + const executeSpy = vi + .spyOn(createRow, 'execute') + .mockImplementation(async (_ctx, i) => ({ id: `row-${i}` })); + // Route both items to the create op. + ctx.getNodeParameter.mockReturnValue('create'); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(executeSpy).toHaveBeenCalledTimes(2); + expect(executeSpy).toHaveBeenNthCalledWith(1, ctx, 0, CREDENTIAL_TYPE); + expect(executeSpy).toHaveBeenNthCalledWith(2, ctx, 1, CREDENTIAL_TYPE); + expect(result[0]).toEqual([ + { json: { id: 'row-0' }, pairedItem: { item: 0 } }, + { json: { id: 'row-1' }, pairedItem: { item: 1 } }, + ]); + }); + + it('keeps prior successes and collects the failure for a mixed batch when continueOnFail is on', async () => { + ctx.getInputData.mockReturnValue([{ json: { i: 0 } }, { json: { i: 1 } }]); + ctx.getNodeParameter.mockReturnValue('create'); + ctx.continueOnFail.mockReturnValue(true); + vi.spyOn(createRow, 'execute') + .mockResolvedValueOnce({ id: 'row-0' }) + .mockRejectedValueOnce(new Error('boom')); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(result[0]).toEqual([ + { json: { id: 'row-0' }, pairedItem: { item: 0 } }, + { json: { error: 'boom' }, pairedItem: { item: 1 } }, + ]); + }); + + it('falls back to String(error) when a non-Error value is thrown', async () => { + ctx.getInputData.mockReturnValue([{ json: {} }]); + ctx.getNodeParameter.mockReturnValue('create'); + ctx.continueOnFail.mockReturnValue(true); + vi.spyOn(createRow, 'execute').mockRejectedValue('plain failure'); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(result).toEqual([[{ json: { error: 'plain failure' }, pairedItem: { item: 0 } }]]); + }); + + it('returns a single empty branch when there are no input items', async () => { + ctx.getInputData.mockReturnValue([]); + + const result = await MicrosoftDataverse.prototype.execute.call(ctx); + + expect(result).toEqual([[]]); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/loadOptions.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/loadOptions.test.ts new file mode 100644 index 00000000000..639d60677be --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/loadOptions.test.ts @@ -0,0 +1,414 @@ +// Fixtures and expected values below mirror Dataverse API responses (lowercase +// logical names, `{ name, value }` option shapes) — not node display-name params. +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ + +import type { ILoadOptionsFunctions, INode, JsonObject } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { + getEntitySets, + getReadColumns, + getWriteColumns, + searchEntitySets, + searchRows, +} from '../loadOptions'; + +const BASE_URL = 'https://org.crm.dynamics.com'; + +const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +describe('Microsoft Dataverse loadOptions', () => { + let ctx: ReturnType>; + let request: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + ctx.getCredentials.mockResolvedValue({ environmentUrl: BASE_URL }); + request = vi.fn(); + ctx.helpers.httpRequestWithAuthentication = request as never; + }); + + describe('getEntitySets', () => { + it('maps entity definitions to sorted dropdown options', async () => { + request.mockResolvedValue({ + value: [ + { + LogicalName: 'account', + EntitySetName: 'accounts', + DisplayName: { UserLocalizedLabel: { Label: 'Account' } }, + }, + { + LogicalName: 'contact', + EntitySetName: 'contacts', + DisplayName: { UserLocalizedLabel: { Label: 'Contact' } }, + }, + ], + }); + + const options = await getEntitySets.call(ctx); + + expect(options).toEqual([ + { name: 'Account (accounts)', value: 'accounts', description: 'Logical name: account' }, + { name: 'Contact (contacts)', value: 'contacts', description: 'Logical name: contact' }, + ]); + }); + + it('skips definitions without an EntitySetName', async () => { + request.mockResolvedValue({ + value: [ + { LogicalName: 'audit', EntitySetName: null }, + { LogicalName: 'account', EntitySetName: 'accounts' }, + ], + }); + + const options = await getEntitySets.call(ctx); + + expect(options).toEqual([ + { name: 'account (accounts)', value: 'accounts', description: 'Logical name: account' }, + ]); + }); + + it('falls back to the logical name when the label is blank', async () => { + request.mockResolvedValue({ + value: [ + { + LogicalName: 'account', + EntitySetName: 'accounts', + DisplayName: { UserLocalizedLabel: { Label: ' ' } }, + }, + ], + }); + + const [option] = await getEntitySets.call(ctx); + + expect(option.name).toBe('account (accounts)'); + }); + + it('throws when the credential is missing an environment URL', async () => { + ctx.getCredentials.mockResolvedValue({ environmentUrl: '' }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow(NodeApiError); + expect(request).not.toHaveBeenCalled(); + }); + + it('wraps an upstream failure in NodeApiError with the Dataverse message', async () => { + request.mockRejectedValue({ + statusCode: 400, + response: { body: { error: { code: '0x80060888', message: 'Invalid $select' } } }, + }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow(/Invalid \$select/); + }); + + it('recovers the message from the legacy OData v3 error shape', async () => { + request.mockRejectedValue({ + statusCode: 400, + response: { body: { error: { message: { lang: 'en-US', value: 'Legacy message' } } } }, + }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow(/Legacy message/); + }); + + it('recovers the message from a nested cause.response.body envelope', async () => { + request.mockRejectedValue({ + message: 'Bad request - please check your parameters', + cause: { + statusCode: 403, + response: { body: { error: { code: '0x80072560', message: 'Insufficient privileges' } } }, + }, + }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow(/Insufficient privileges/); + }); + + it('applies the crafted message when the failure is already a NodeApiError', async () => { + // Production shape: httpRequestWithAuthentication pre-wraps the axios + // failure in a NodeApiError, which lifts the body text onto `.description`. + const cause = Object.assign(new Error('Forbidden - perhaps check your credentials?'), { + statusCode: 403, + response: { data: { error: { code: '0x80072560', message: 'Insufficient privileges' } } }, + }); + request.mockRejectedValue(new NodeApiError(node, cause as unknown as JsonObject)); + + const error = await getEntitySets.call(ctx).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(NodeApiError); + expect((error as Error).message).toContain('Insufficient privileges'); + expect((error as Error).message).not.toContain('perhaps check your credentials'); + }); + + it('parses a raw JSON string error body', async () => { + request.mockRejectedValue({ + statusCode: 400, + response: { body: JSON.stringify({ error: { message: 'Stringified body' } }) }, + }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow(/Stringified body/); + }); + + it('falls back to the wrapper description when no body is reachable', async () => { + request.mockRejectedValue({ + statusCode: 400, + message: 'Bad request - please check your parameters', + description: 'The authorization server denied the request', + }); + + await expect(getEntitySets.call(ctx)).rejects.toThrow( + /The authorization server denied the request/, + ); + }); + }); + + describe('getReadColumns', () => { + it('returns an empty list when no table is selected', async () => { + ctx.getCurrentNodeParameter.mockReturnValue(''); + + expect(await getReadColumns.call(ctx)).toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); + + it('looks up the logical name then maps readable attributes', async () => { + ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' }); + request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({ + value: [ + { + LogicalName: 'name', + DisplayName: { UserLocalizedLabel: { Label: 'Name' } }, + }, + { + LogicalName: 'primarycontactid', + AttributeType: 'Lookup', + DisplayName: { UserLocalizedLabel: { Label: 'Primary Contact' } }, + }, + { + LogicalName: 'customerid', + AttributeType: 'Customer', + DisplayName: { UserLocalizedLabel: { Label: 'Customer' } }, + }, + { + LogicalName: 'ownerid', + AttributeType: 'Owner', + DisplayName: { UserLocalizedLabel: { Label: 'Owner' } }, + }, + { LogicalName: 'accountidname', AttributeOf: 'accountid' }, + { LogicalName: 'hidden', IsValidForRead: false }, + { + LogicalName: 'to', + AttributeType: 'PartyList', + IsValidForRead: true, + IsValidODataAttribute: false, + }, + ], + }); + + const options = await getReadColumns.call(ctx); + + expect(options).toEqual([ + { name: 'Customer (customerid) — lookup', value: '_customerid_value' }, + { name: 'Name (name)', value: 'name' }, + { name: 'Owner (ownerid) — lookup', value: '_ownerid_value' }, + { + name: 'Primary Contact (primarycontactid) — lookup', + value: '_primarycontactid_value', + }, + ]); + const [, attrOptions] = request.mock.calls[1]; + expect(attrOptions.qs.$select).toContain('AttributeType'); + expect(attrOptions.qs.$select).toContain('IsValidODataAttribute'); + expect(attrOptions.qs.$select).toContain('IsValidForCreate'); + expect(attrOptions.qs.$select).toContain('IsValidForUpdate'); + }); + + it('returns an empty list when the table lookup finds nothing', async () => { + ctx.getCurrentNodeParameter.mockReturnValue('accounts'); + request.mockResolvedValueOnce({ value: [] }); + + expect(await getReadColumns.call(ctx)).toEqual([]); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('escapes single quotes in the entity-set lookup filter', async () => { + ctx.getCurrentNodeParameter.mockReturnValue("o'brien"); + request.mockResolvedValueOnce({ value: [] }); + + await getReadColumns.call(ctx); + + const [, options] = request.mock.calls[0]; + expect(options.qs.$filter).toBe("EntitySetName eq 'o''brien'"); + }); + }); + + describe('getWriteColumns', () => { + it.each([ + ['create', 'IsValidForCreate', 'createonly'], + ['update', 'IsValidForUpdate', 'updateonly'], + ] as const)('filters %s fields by %s', async (operation, validityProperty, includedName) => { + ctx.getCurrentNodeParameter.mockImplementation((parameterName) => + parameterName === 'entitySet' ? 'accounts' : operation, + ); + request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({ + value: [ + { LogicalName: includedName, [validityProperty]: true }, + { LogicalName: 'computed', [validityProperty]: false }, + { + LogicalName: 'primarycontactid', + AttributeType: 'Lookup', + [validityProperty]: true, + }, + { + LogicalName: 'requiredattendees', + AttributeType: 'PartyList', + [validityProperty]: true, + IsValidODataAttribute: false, + }, + ], + }); + + const options = await getWriteColumns.call(ctx); + expect(options).toHaveLength(2); + expect(options).toEqual( + expect.arrayContaining([ + { name: `${includedName} (${includedName})`, value: includedName }, + { + name: 'primarycontactid (primarycontactid) — lookup', + value: 'primarycontactid', + }, + ]), + ); + }); + + it('only offers fields valid for both create and update during upsert', async () => { + ctx.getCurrentNodeParameter.mockImplementation((parameterName) => + parameterName === 'entitySet' ? 'accounts' : 'upsert', + ); + request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({ + value: [ + { LogicalName: 'name', IsValidForCreate: true, IsValidForUpdate: true }, + { LogicalName: 'createdon', IsValidForCreate: false, IsValidForUpdate: false }, + { LogicalName: 'createonly', IsValidForCreate: true, IsValidForUpdate: false }, + ], + }); + + expect(await getWriteColumns.call(ctx)).toEqual([{ name: 'name (name)', value: 'name' }]); + }); + }); + + describe('listSearch', () => { + it('filters table locator results by display name', async () => { + request.mockResolvedValue({ + value: [ + { + LogicalName: 'account', + EntitySetName: 'accounts', + DisplayName: { UserLocalizedLabel: { Label: 'Account' } }, + }, + { + LogicalName: 'contact', + EntitySetName: 'contacts', + DisplayName: { UserLocalizedLabel: { Label: 'Contact' } }, + }, + ], + }); + + expect(await searchEntitySets.call(ctx, 'acc')).toEqual({ + results: [{ name: 'Account (accounts)', value: 'accounts' }], + }); + }); + + it('searches rows by the selected table primary name and returns primary IDs', async () => { + ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: ' /accounts/\r\n' }); + request + .mockResolvedValueOnce({ + value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }], + }) + .mockResolvedValueOnce({ + value: [{ accountid: 'row-1', name: 'Acme' }], + }); + + expect(await searchRows.call(ctx, "Ac'me")).toEqual({ + results: [{ name: 'Acme (row-1)', value: 'row-1' }], + }); + const [, metadataRequest] = request.mock.calls[0]; + expect(metadataRequest.qs.$filter).toBe("EntitySetName eq 'accounts'"); + const [, rowsRequest] = request.mock.calls[1]; + expect(rowsRequest.url).toContain('/accounts'); + expect(rowsRequest.qs).toEqual({ + $select: 'accountid,name', + $filter: "contains(name,'Ac''me')", + }); + // Server-driven paging, not a `$top` cap. + expect(rowsRequest.qs.$top).toBeUndefined(); + expect(rowsRequest.headers.Prefer).toBe('odata.maxpagesize=100'); + }); + + it('rejects an invalid table name before requesting metadata', async () => { + ctx.getCurrentNodeParameter.mockReturnValue('accounts/../contacts'); + + await expect(searchRows.call(ctx)).rejects.toThrow(/valid Dataverse table name/); + expect(request).not.toHaveBeenCalled(); + }); + + it('returns the @odata.nextLink as the pagination token', async () => { + ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' }); + const nextLink = `${BASE_URL}/api/data/v9.2/accounts?$select=accountid,name&$skiptoken=x`; + request + .mockResolvedValueOnce({ + value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }], + }) + .mockResolvedValueOnce({ + value: [{ accountid: 'row-1', name: 'Acme' }], + '@odata.nextLink': nextLink, + }); + + expect(await searchRows.call(ctx)).toEqual({ + results: [{ name: 'Acme (row-1)', value: 'row-1' }], + paginationToken: nextLink, + }); + }); + + it('follows the pagination token as the request URL without resending the query', async () => { + ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' }); + const token = `${BASE_URL}/api/data/v9.2/accounts?$select=accountid,name&$skiptoken=x`; + request + .mockResolvedValueOnce({ + value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }], + }) + .mockResolvedValueOnce({ + value: [{ accountid: 'row-2', name: 'Beta' }], + }); + + expect(await searchRows.call(ctx, undefined, token)).toEqual({ + results: [{ name: 'Beta (row-2)', value: 'row-2' }], + }); + const [, pageRequest] = request.mock.calls[1]; + expect(pageRequest.url).toBe(token); + // The nextLink already encodes the query; don't resend it. + expect(pageRequest.qs).toEqual({}); + expect(pageRequest.headers.Prefer).toBe('odata.maxpagesize=100'); + }); + + it('rejects a pagination token pointing outside the environment URL', async () => { + ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' }); + const evilToken = 'https://evil.example.com/api/data/v9.2/accounts?$skiptoken=x'; + request.mockResolvedValueOnce({ + value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }], + }); + + await expect(searchRows.call(ctx, undefined, evilToken)).rejects.toThrow( + /outside the configured Environment URL/, + ); + // Only the metadata call ran; the cross-host page request was never dispatched. + expect(request).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/lookups.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/lookups.test.ts new file mode 100644 index 00000000000..85b39c6a0ff --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/lookups.test.ts @@ -0,0 +1,513 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { dataverseApiRequest } from '../GenericFunctions'; +import { + applyLookupBindings, + bodyHasLookupCandidates, + resolveLookupFields, + type LookupFieldMap, +} from '../operations/lookups'; + +vi.mock('../GenericFunctions', () => ({ + dataverseApiRequest: vi.fn(), +})); + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; + +const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +describe('Microsoft Dataverse lookups', () => { + let ctx: ReturnType>; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + }); + + describe('resolveLookupFields', () => { + // Wire the three metadata calls: entity-set→logical name, relationships, + // referenced logical names→entity-set names. + const mockMetadata = () => { + vi.mocked(dataverseApiRequest) + .mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }) + .mockResolvedValueOnce({ + value: [ + { + ReferencingAttribute: 'primarycontactid', + ReferencingEntityNavigationPropertyName: 'primarycontactid', + ReferencedEntity: 'contact', + }, + { + ReferencingAttribute: 'customerid', + ReferencingEntityNavigationPropertyName: 'customerid_account', + ReferencedEntity: 'account', + }, + { + ReferencingAttribute: 'customerid', + ReferencingEntityNavigationPropertyName: 'customerid_contact', + ReferencedEntity: 'contact', + }, + ], + }) + .mockResolvedValueOnce({ + value: [ + { + LogicalName: 'contact', + EntitySetName: 'contacts', + PrimaryIdAttribute: 'contactid', + TableType: 'Standard', + }, + { + LogicalName: 'account', + EntitySetName: 'accounts', + PrimaryIdAttribute: 'accountid', + TableType: 'Standard', + }, + ], + }); + }; + + it('builds a map of single-target and polymorphic lookups', async () => { + mockMetadata(); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.get('primarycontactid')).toEqual([ + { + navigationProperty: 'primarycontactid', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + tableType: 'Standard', + primaryIdAttribute: 'contactid', + }, + ]); + expect(map.get('customerid')).toEqual([ + { + navigationProperty: 'customerid_account', + referencedEntity: 'account', + targetEntitySet: 'accounts', + tableType: 'Standard', + primaryIdAttribute: 'accountid', + }, + { + navigationProperty: 'customerid_contact', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + tableType: 'Standard', + primaryIdAttribute: 'contactid', + }, + ]); + }); + + it('memoizes metadata per execution and entity set', async () => { + mockMetadata(); + + await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + // Three metadata calls total, not six. + expect(dataverseApiRequest).toHaveBeenCalledTimes(3); + }); + + it('expands the abstract owner target into systemuser and team candidates', async () => { + vi.mocked(dataverseApiRequest) + .mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }) + .mockResolvedValueOnce({ + value: [ + { + ReferencingAttribute: 'ownerid', + ReferencingEntityNavigationPropertyName: 'ownerid', + ReferencedEntity: 'owner', + }, + ], + }) + .mockResolvedValueOnce({ + value: [ + { LogicalName: 'systemuser', EntitySetName: 'systemusers' }, + { LogicalName: 'team', EntitySetName: 'teams' }, + ], + }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.get('ownerid')).toEqual([ + { + navigationProperty: 'ownerid', + referencedEntity: 'systemuser', + targetEntitySet: 'systemusers', + }, + { + navigationProperty: 'ownerid', + referencedEntity: 'team', + targetEntitySet: 'teams', + }, + ]); + }); + + it('returns an empty map when the table has no lookups', async () => { + vi.mocked(dataverseApiRequest) + .mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }) + .mockResolvedValueOnce({ value: [] }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.size).toBe(0); + }); + + it('returns an empty map when the entity set is unknown', async () => { + // No matching EntityDefinition — an empty result, not an error. + vi.mocked(dataverseApiRequest).mockResolvedValueOnce({ value: [] }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'unknowns'); + + expect(map.size).toBe(0); + // Only the logical-name lookup runs; no relationship calls follow. + expect(dataverseApiRequest).toHaveBeenCalledTimes(1); + }); + + it('propagates a failed metadata request instead of degrading', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValueOnce(new Error('boom')); + + await expect(resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts')).rejects.toThrow('boom'); + }); + + it('degrades to an empty map when metadata read is forbidden (403)', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '403' }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.size).toBe(0); + }); + + it('degrades to an empty map when metadata read is unauthorized (401)', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '401' }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.size).toBe(0); + }); + + it('detects a permission error nested under error.cause', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValue({ + cause: { response: { status: 403 } }, + }); + + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.size).toBe(0); + }); + + it('caches the best-effort empty map so a later item does not re-request', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '403' }); + + await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + // The forbidden metadata endpoint is hit once, not once per item. + expect(dataverseApiRequest).toHaveBeenCalledTimes(1); + }); + + it('does not cache a failed resolution so a later item can retry', async () => { + vi.mocked(dataverseApiRequest).mockRejectedValueOnce(new Error('boom')); + await expect(resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts')).rejects.toThrow('boom'); + + // A retry re-issues the metadata calls and succeeds. + mockMetadata(); + const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts'); + + expect(map.get('primarycontactid')).toBeDefined(); + }); + }); + + describe('applyLookupBindings', () => { + const singleTarget: LookupFieldMap = new Map([ + [ + 'primarycontactid', + [ + { + navigationProperty: 'primarycontactid', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + }, + ], + ], + ]); + const polymorphic: LookupFieldMap = new Map([ + [ + 'customerid', + [ + { + navigationProperty: 'customerid_account', + referencedEntity: 'account', + targetEntitySet: 'accounts', + }, + { + navigationProperty: 'customerid_contact', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + }, + ], + ], + ]); + const guid = '00000000-0000-0000-0000-000000000001'; + + // `ownerid` reports the abstract `owner` entity, expanded into its two + // concrete members sharing the one navigation property. + const owner: LookupFieldMap = new Map([ + [ + 'ownerid', + [ + { + navigationProperty: 'ownerid', + referencedEntity: 'systemuser', + targetEntitySet: 'systemusers', + }, + { + navigationProperty: 'ownerid', + referencedEntity: 'team', + targetEntitySet: 'teams', + }, + ], + ], + ]); + + it('binds a systemusers reference for the owner lookup', () => { + const out = applyLookupBindings(ctx, 0, { ownerid: `/systemusers(${guid})` }, owner); + + expect(out).toEqual({ 'ownerid@odata.bind': `/systemusers(${guid})` }); + }); + + it('binds a teams reference for the owner lookup', () => { + const out = applyLookupBindings(ctx, 0, { ownerid: `/teams(${guid})` }, owner); + + expect(out).toEqual({ 'ownerid@odata.bind': `/teams(${guid})` }); + }); + + it('rejects a bare GUID for the owner lookup', () => { + expect(() => applyLookupBindings(ctx, 0, { ownerid: guid }, owner)).toThrow( + /multiple tables/, + ); + }); + + it('binds a bare GUID for a single-target lookup', () => { + const out = applyLookupBindings(ctx, 0, { primarycontactid: guid }, singleTarget); + + expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` }); + }); + + it('rejects a bare GUID for an elastic-table lookup', () => { + const elasticTarget: LookupFieldMap = new Map([ + [ + 'primarycontactid', + [ + { + navigationProperty: 'primarycontactid', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + tableType: 'Elastic', + primaryIdAttribute: 'contactid', + }, + ], + ], + ]); + + expect(() => applyLookupBindings(ctx, 0, { primarycontactid: guid }, elasticTarget)).toThrow( + /partitionId/, + ); + }); + + it('builds a partition-aware reference for an elastic-table lookup', () => { + const elasticTarget: LookupFieldMap = new Map([ + [ + 'primarycontactid', + [ + { + navigationProperty: 'primarycontactid', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + tableType: 'Elastic', + primaryIdAttribute: 'contactid', + }, + ], + ], + ]); + + const out = applyLookupBindings( + ctx, + 0, + { primarycontactid: { id: guid, partitionId: "device's partition" } }, + elasticTarget, + ); + + expect(out).toEqual({ + 'primarycontactid@odata.bind': `/contacts(contactid=${guid},partitionid='device''s partition')`, + }); + }); + + it.each(['device?query', 'device#fragment', 'device/path', 'device\\path', 'device\npath'])( + 'rejects a URL-breaking elastic lookup partition ID: %j', + (partitionId) => { + const elasticTarget: LookupFieldMap = new Map([ + [ + 'primarycontactid', + [ + { + navigationProperty: 'primarycontactid', + referencedEntity: 'contact', + targetEntitySet: 'contacts', + tableType: 'Elastic', + primaryIdAttribute: 'contactid', + }, + ], + ], + ]); + + expect(() => + applyLookupBindings( + ctx, + 0, + { primarycontactid: { id: guid, partitionId } }, + elasticTarget, + ), + ).toThrow(/must not contain URL delimiters/); + }, + ); + + it('passes a full reference path through and selects the matching navigation property', () => { + const out = applyLookupBindings(ctx, 0, { customerid: `/accounts(${guid})` }, polymorphic); + + expect(out).toEqual({ 'customerid_account@odata.bind': `/accounts(${guid})` }); + }); + + it('normalizes an entityset(id) reference without a leading slash', () => { + const out = applyLookupBindings(ctx, 0, { customerid: `contacts(${guid})` }, polymorphic); + + expect(out).toEqual({ 'customerid_contact@odata.bind': `/contacts(${guid})` }); + }); + + it('leaves non-lookup fields untouched', () => { + const out = applyLookupBindings(ctx, 0, { name: 'Acme', revenue: 100 }, singleTarget); + + expect(out).toEqual({ name: 'Acme', revenue: 100 }); + }); + + it('passes an already-formed @odata.bind key through', () => { + const out = applyLookupBindings( + ctx, + 0, + { 'primarycontactid@odata.bind': `/contacts(${guid})` }, + singleTarget, + ); + + expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` }); + }); + + it('disassociates a single-target lookup on null', () => { + const out = applyLookupBindings(ctx, 0, { primarycontactid: null }, singleTarget); + + expect(out).toEqual({ primarycontactid: null }); + }); + + it('rejects a bare GUID for a polymorphic lookup', () => { + expect(() => applyLookupBindings(ctx, 0, { customerid: guid }, polymorphic)).toThrow( + NodeOperationError, + ); + expect(() => applyLookupBindings(ctx, 0, { customerid: guid }, polymorphic)).toThrow( + /multiple tables/, + ); + }); + + it('rejects clearing a polymorphic lookup with null', () => { + expect(() => applyLookupBindings(ctx, 0, { customerid: null }, polymorphic)).toThrow( + NodeOperationError, + ); + }); + + it('rejects a value that is neither a GUID nor a reference', () => { + expect(() => + applyLookupBindings(ctx, 0, { primarycontactid: 'not-a-guid' }, singleTarget), + ).toThrow(/record GUID or a/); + }); + + it('rejects a malformed slash-prefixed reference with no key', () => { + // "/contacts" starts with a slash but has no "(id)" — must not be forwarded. + expect(() => + applyLookupBindings(ctx, 0, { primarycontactid: '/contacts' }, singleTarget), + ).toThrow(/record GUID or a/); + }); + + it('binds a matching-table reference for a single-target lookup', () => { + const out = applyLookupBindings( + ctx, + 0, + { primarycontactid: `/contacts(${guid})` }, + singleTarget, + ); + + expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` }); + }); + + it('rejects a reference to the wrong table for a single-target lookup', () => { + // The lookup only targets "contacts"; an "/accounts(...)" reference must fail + // node-side instead of forwarding a mismatched path to Dataverse. + expect(() => + applyLookupBindings(ctx, 0, { primarycontactid: `/accounts(${guid})` }, singleTarget), + ).toThrow(/does not match any related table/); + }); + }); + + describe('bodyHasLookupCandidates', () => { + const guid = '00000000-0000-0000-0000-000000000001'; + + it('returns false for a body of only non-string scalars', () => { + expect(bodyHasLookupCandidates({ revenue: 100, active: true })).toBe(false); + }); + + it('returns true for any non-empty string value (may be a mistyped lookup)', () => { + // A plain name on a lookup column must still trigger resolution so it is + // validated node-side rather than forwarded as a raw field. + expect(bodyHasLookupCandidates({ name: 'Acme' })).toBe(true); + expect(bodyHasLookupCandidates({ primarycontactid: 'John Smith' })).toBe(true); + }); + + it('returns false for an empty or whitespace-only string', () => { + expect(bodyHasLookupCandidates({ name: '' })).toBe(false); + expect(bodyHasLookupCandidates({ name: ' ' })).toBe(false); + }); + + it('returns true for a bare GUID value', () => { + expect(bodyHasLookupCandidates({ primarycontactid: guid })).toBe(true); + }); + + it('returns true for a reference-path value (with or without leading slash)', () => { + expect(bodyHasLookupCandidates({ customerid: `/accounts(${guid})` })).toBe(true); + expect(bodyHasLookupCandidates({ customerid: `accounts(${guid})` })).toBe(true); + }); + + it('returns true for a null value (potential disassociation)', () => { + expect(bodyHasLookupCandidates({ primarycontactid: null })).toBe(true); + }); + + it('returns true for a structured elastic lookup value', () => { + expect( + bodyHasLookupCandidates({ primarycontactid: { id: guid, partitionId: 'device' } }), + ).toBe(true); + }); + + it('ignores keys that already carry @odata.bind', () => { + expect(bodyHasLookupCandidates({ 'primarycontactid@odata.bind': `/contacts(${guid})` })).toBe( + false, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/metadata.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/metadata.test.ts new file mode 100644 index 00000000000..d0b92d4d870 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/metadata.test.ts @@ -0,0 +1,38 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { dataverseApiRequest } from '../GenericFunctions'; +import { isElasticTable, resolveTableMetadata } from '../operations/metadata'; + +vi.mock('../GenericFunctions', () => ({ + dataverseApiRequest: vi.fn(), +})); + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; + +describe('Microsoft Dataverse table metadata', () => { + it('resolves and caches table metadata per execution', async () => { + const ctx = mockDeep(); + ctx.getNode.mockReturnValue({ name: 'Microsoft Dataverse' } as INode); + vi.mocked(dataverseApiRequest).mockResolvedValue({ + value: [ + { + LogicalName: 'sensordata', + EntitySetName: 'sensordatas', + PrimaryIdAttribute: 'sensordataid', + TableType: 'Elastic', + }, + ], + }); + + const first = await resolveTableMetadata(ctx, CREDENTIAL_TYPE, 'sensordatas'); + const second = await resolveTableMetadata(ctx, CREDENTIAL_TYPE, 'sensordatas'); + + expect(first).toEqual(second); + expect(first && isElasticTable(first)).toBe(true); + expect(dataverseApiRequest).toHaveBeenCalledTimes(1); + expect(vi.mocked(dataverseApiRequest).mock.calls[0]?.[4]).toMatchObject({ + $select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/operations.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/operations.test.ts new file mode 100644 index 00000000000..5a672564d1d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/operations.test.ts @@ -0,0 +1,679 @@ +import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { + dataverseApiRequest, + dataverseApiRequestAllItems, + dataverseApiRequestWithResponse, +} from '../GenericFunctions'; +import { applyLookupBindings, resolveLookupFields } from '../operations/lookups'; +import { createRow } from '../operations/createRow'; +import { deleteRow } from '../operations/deleteRow'; +import { getRow } from '../operations/getRow'; +import { getManyRows } from '../operations/getManyRows'; +import { resolveTableMetadata } from '../operations/metadata'; +import { updateRow } from '../operations/updateRow'; +import { upsertRow } from '../operations/upsertRow'; + +vi.mock('../GenericFunctions', () => ({ + dataverseApiRequest: vi.fn(), + dataverseApiRequestAllItems: vi.fn(), + dataverseApiRequestWithResponse: vi.fn(), +})); + +vi.mock('../operations/metadata', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveTableMetadata: vi.fn().mockResolvedValue({ + logicalName: 'account', + entitySetName: 'accounts', + primaryIdAttribute: 'accountid', + tableType: 'Standard', + }), + }; +}); + +// Lookup translation is unit-tested in lookups.test.ts. Here we stub only the +// network-bound resolve/apply pair to a pass-through so the write-op assertions +// stay focused on request shape, while keeping the real `bodyHasLookupCandidates` +// gate + `EMPTY_LOOKUP_FIELDS` so the "skip metadata for plain bodies" behavior +// is exercised end-to-end. +vi.mock('../operations/lookups', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveLookupFields: vi.fn().mockResolvedValue(new Map()), + applyLookupBindings: vi.fn((_ctx: unknown, _i: number, body: unknown) => body), + }; +}); + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; +const ROW_ID = '00000000-0000-0000-0000-000000000001'; + +const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +describe('Microsoft Dataverse operations', () => { + let ctx: ReturnType>; + + /** + * Drive `ctx.getNodeParameter(name, index, fallback)` from a plain map, + * honoring the caller's fallback when a key is absent — mirrors n8n's real + * behavior so each op's optional-param defaults are exercised. + */ + const withParams = (params: Record) => { + ctx.getNodeParameter.mockImplementation( + (name: string, _i: number, fallback?: unknown) => + (name in params ? params[name] : fallback) as never, + ); + }; + + /** Args passed to the single-shot request: [ctx, method, path, body, qs, headers, credType]. */ + const singleCall = () => vi.mocked(dataverseApiRequest).mock.calls[0]!; + /** Args passed to the paged request: [ctx, method, path, qs, limit, credType, headers]. */ + const pagedCall = () => vi.mocked(dataverseApiRequestAllItems).mock.calls[0]!; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + vi.mocked(dataverseApiRequest).mockResolvedValue({ id: 'row-1' }); + vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([{ id: 'row-1' }]); + vi.mocked(dataverseApiRequestWithResponse).mockResolvedValue({ + body: { id: 'row-1' }, + headers: { 'x-ms-session-token': 'session-token' }, + statusCode: 200, + }); + }); + + describe('createRow', () => { + it('POSTs the parsed body with return=representation', async () => { + withParams({ + entitySet: 'accounts', + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + createOptions: {}, + }); + + const result = await createRow.execute(ctx, 0, CREDENTIAL_TYPE); + + expect(result).toEqual({ id: 'row-1' }); + const [, method, path, body, , headers, credType] = singleCall(); + expect(method).toBe('POST'); + expect(path).toBe('/accounts'); + expect(body).toEqual({ name: 'Acme' }); + expect(headers?.Prefer).toBe('return=representation'); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('resolves lookup fields and translates the body when it carries a lookup-style value', async () => { + withParams({ + entitySet: 'accounts', + inputMode: 'json', + fieldsJson: '{"primarycontactid":"00000000-0000-0000-0000-000000000001"}', + createOptions: {}, + }); + + await createRow.execute(ctx, 0, CREDENTIAL_TYPE); + + expect(resolveLookupFields).toHaveBeenCalledWith(ctx, CREDENTIAL_TYPE, 'accounts'); + expect(applyLookupBindings).toHaveBeenCalled(); + }); + + it('skips lookup metadata for a body with only non-string scalars', async () => { + withParams({ + entitySet: 'accounts', + inputMode: 'json', + fieldsJson: '{"revenue":100,"active":true}', + createOptions: {}, + }); + + await createRow.execute(ctx, 0, CREDENTIAL_TYPE); + + // No string / null value in the body → no metadata GETs, but the body + // still passes through applyLookupBindings (prototype-pollution sanitizing). + expect(resolveLookupFields).not.toHaveBeenCalled(); + expect(applyLookupBindings).toHaveBeenCalled(); + }); + + it('resolves lookup metadata for a body with a plain string value', async () => { + withParams({ + entitySet: 'accounts', + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + createOptions: {}, + }); + + await createRow.execute(ctx, 0, CREDENTIAL_TYPE); + + // A plain string could be a mistyped lookup value; metadata read is + // best-effort, so resolving it is safe even without permission. + expect(resolveLookupFields).toHaveBeenCalledWith(ctx, CREDENTIAL_TYPE, 'accounts'); + }); + + it('throws when the body is empty', async () => { + withParams({ entitySet: 'accounts', inputMode: 'json', fieldsJson: '{}', createOptions: {} }); + + await expect(createRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow( + /Create requires at least one field/, + ); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + // The empty body is rejected before any lookup metadata is fetched. + expect(resolveLookupFields).not.toHaveBeenCalled(); + }); + }); + + describe('getRow', () => { + it('GETs the record path and maps select/expand into the query string', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + getOptions: { select: 'name', expand: 'primarycontactid', partitionId: 'partition-1' }, + }); + + await getRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, method, path, , qs, , credType] = singleCall(); + expect(method).toBe('GET'); + expect(path).toBe(`/accounts(${ROW_ID})`); + expect(qs).toMatchObject({ + $select: 'name', + $expand: 'primarycontactid', + partitionId: 'partition-1', + }); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('throws for a whitespace-only record id before dispatching', async () => { + withParams({ entitySet: 'accounts', recordId: ' ', getOptions: {} }); + + await expect(getRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/required/); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + }); + + it('forwards a session token for a consistent read', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + getOptions: { sessionToken: 'token-from-write' }, + }); + + await getRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , , , headers] = singleCall(); + expect(headers?.['MSCRM.SessionToken']).toBe('token-from-write'); + }); + + it('resolves table and row resource locator values', async () => { + withParams({ + entitySet: { mode: 'list', value: 'accounts' }, + recordId: { mode: 'list', value: ROW_ID }, + getOptions: {}, + }); + + await getRow.execute(ctx, 0, CREDENTIAL_TYPE); + + expect(singleCall()[2]).toBe(`/accounts(${ROW_ID})`); + }); + + it('rejects a table name containing path traversal before dispatching', async () => { + withParams({ entitySet: 'accounts\\..\\contacts', recordId: ROW_ID, getOptions: {} }); + + await expect(getRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"entitySet"/); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + }); + }); + + describe('getManyRows', () => { + it('rejects control characters inside the table name before dispatching', async () => { + withParams({ entitySet: 'accounts\ncontacts', returnAll: true, getAllOptions: {} }); + + await expect(getManyRows.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"entitySet"/); + expect(dataverseApiRequestAllItems).not.toHaveBeenCalled(); + }); + + it('does not expose a Skip Token option', () => { + const options = getManyRows.properties.find((property) => property.name === 'getAllOptions'); + + expect(options?.options?.some((option) => option.name === 'skiptoken')).toBe(false); + }); + + it('pages with a limit and derives $orderby from column + direction', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 25, + getAllOptions: { + orderbyColumn: 'name', + orderbyDirection: 'desc', + filter: 'statecode eq 0', + }, + }); + + const result = await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + expect(result).toEqual([{ id: 'row-1' }]); + const [, method, path, qs, limit, credType] = pagedCall(); + expect(method).toBe('GET'); + expect(path).toBe('/accounts'); + expect(qs).toMatchObject({ $orderby: 'name desc', $filter: 'statecode eq 0' }); + // Limit is mapped to a server-side $top so a full page isn't fetched. + expect(qs).toMatchObject({ $top: 25 }); + expect(limit).toBe(25); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('maps a Limit of 5,000 to $top', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 5000, + getAllOptions: {}, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs, limit] = pagedCall(); + expect(qs).toMatchObject({ $top: 5000 }); + expect(limit).toBe(5000); + }); + + it('keeps server-driven paging for a Limit above 5,000', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 6000, + getAllOptions: {}, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs, limit] = pagedCall(); + expect(qs).not.toHaveProperty('$top'); + expect(limit).toBe(6000); + }); + + it('lets an explicit Row Count override the limit-derived $top', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 25, + getAllOptions: { top: 10 }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs, limit] = pagedCall(); + expect(qs).toMatchObject({ $top: 10 }); + expect(limit).toBe(25); + }); + + it('uses limit 0 (unbounded) when returnAll is true', async () => { + withParams({ entitySet: 'accounts', returnAll: true, getAllOptions: {} }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs, limit] = pagedCall(); + expect(limit).toBe(0); + // Return All must not cap the server page. + expect(qs).not.toHaveProperty('$top'); + }); + + it('forwards Row Count as $top', async () => { + withParams({ + entitySet: 'accounts', + returnAll: true, + getAllOptions: { top: 250 }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs] = pagedCall(); + expect(qs).toMatchObject({ $top: 250 }); + }); + + it('ignores a stale saved Skip Token option', async () => { + withParams({ + entitySet: 'accounts', + returnAll: true, + getAllOptions: { skiptoken: 'already%2Fencoded' }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs] = pagedCall(); + expect(qs).not.toHaveProperty('$skiptoken'); + }); + + it('rejects FetchXML queries when returnAll is enabled', async () => { + withParams({ + entitySet: 'accounts', + returnAll: true, + getAllOptions: { fetchXml: 'x' }, + }); + + await expect(getManyRows.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow( + 'FetchXML Query cannot be used with Return All because FetchXML pagination is not supported', + ); + expect(dataverseApiRequestAllItems).not.toHaveBeenCalled(); + }); + + it('forwards a limited FetchXML query verbatim and ignores OData options', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 25, + getAllOptions: { + fetchXml: 'x', + filter: 'ignored', + select: ['ignored'], + }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, method, path, qs, limit] = pagedCall(); + expect(method).toBe('GET'); + expect(path).toBe('/accounts'); + expect(qs).toEqual({ fetchXml: 'x' }); + expect(limit).toBe(25); + }); + + it('treats a whitespace-only FetchXML as unset and uses OData options', async () => { + withParams({ + entitySet: 'accounts', + returnAll: false, + limit: 25, + getAllOptions: { fetchXml: ' ', filter: 'statecode eq 0' }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , qs] = pagedCall(); + expect(qs).not.toHaveProperty('fetchXml'); + expect(qs).toMatchObject({ $filter: 'statecode eq 0', $top: 25 }); + }); + + it('forwards a session token to every paged request', async () => { + withParams({ + entitySet: 'accounts', + returnAll: true, + getAllOptions: { sessionToken: 'token-from-write' }, + }); + + await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , , , , headers] = pagedCall(); + expect(headers?.['MSCRM.SessionToken']).toBe('token-from-write'); + }); + }); + + describe('updateRow', () => { + it('explains how to address a partitioned elastic row', () => { + const rowId = updateRow.properties.find((property) => property.name === 'recordId'); + + expect(rowId?.description).toContain( + 'For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.', + ); + }); + + it('PATCHes with If-Match: * and return=representation', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"New"}', + updateOptions: {}, + }); + + await updateRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, method, path, body, , headers, credType] = singleCall(); + expect(method).toBe('PATCH'); + expect(path).toBe(`/accounts(${ROW_ID})`); + expect(body).toEqual({ name: 'New' }); + expect(headers?.['If-Match']).toBe('*'); + expect(headers?.Prefer).toBe('return=representation'); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('throws with the update-specific message when the body is empty', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{}', + updateOptions: {}, + }); + + await expect(updateRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow( + /Update requires at least one field/, + ); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + // The empty body is rejected before any lookup metadata is fetched. + expect(resolveLookupFields).not.toHaveBeenCalled(); + }); + + it('returns the session token when requested', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"New"}', + updateOptions: { returnSessionToken: true, sessionToken: 'earlier-token' }, + }); + + const result = await updateRow.execute(ctx, 0, CREDENTIAL_TYPE); + + expect(result).toEqual({ data: { id: 'row-1' }, sessionToken: 'session-token' }); + const [, method, , , , headers] = vi.mocked(dataverseApiRequestWithResponse).mock.calls[0]!; + expect(method).toBe('PATCH'); + expect(headers?.['MSCRM.SessionToken']).toBe('earlier-token'); + }); + }); + + describe('upsertRow', () => { + it('explains how to address a partitioned elastic row by GUID', () => { + const rowId = upsertRow.properties.find((property) => property.name === 'recordId'); + + expect(rowId?.description).toContain( + 'For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.', + ); + }); + + it('PATCHes by GUID with no precondition header for the default behavior', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: {}, + }); + + await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, method, path, body, , headers, credType] = singleCall(); + expect(method).toBe('PATCH'); + expect(path).toBe(`/accounts(${ROW_ID})`); + expect(body).toEqual({ name: 'Acme' }); + expect(headers?.['If-Match']).toBeUndefined(); + expect(headers?.['If-None-Match']).toBeUndefined(); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('adds If-Match: * for updateOnly behavior', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: { behavior: 'updateOnly' }, + }); + + await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , , , headers] = singleCall(); + expect(headers?.['If-Match']).toBe('*'); + }); + + it('adds If-None-Match: * for createOnly behavior', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: { behavior: 'createOnly' }, + }); + + await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , , , , headers] = singleCall(); + expect(headers?.['If-None-Match']).toBe('*'); + }); + + it('blocks replacement upsert for an elastic table without acknowledgment', async () => { + vi.mocked(resolveTableMetadata).mockResolvedValueOnce({ + logicalName: 'sensordata', + entitySetName: 'sensordatas', + primaryIdAttribute: 'sensordataid', + tableType: 'Elastic', + }); + withParams({ + entitySet: 'sensordatas', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"reading"}', + upsertOptions: {}, + }); + + await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow( + /Allow Elastic Table Replacement/, + ); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + }); + + it('allows replacement upsert for an elastic table after acknowledgment', async () => { + withParams({ + entitySet: 'sensordatas', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{"name":"reading"}', + upsertOptions: { allowElasticTableReplacement: true }, + }); + + await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, method, path] = singleCall(); + expect(method).toBe('PATCH'); + expect(path).toBe(`/sensordatas(${ROW_ID})`); + expect(resolveTableMetadata).not.toHaveBeenCalled(); + }); + + it('addresses the row by an alternate-key predicate', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'alternateKey', + alternateKey: "accountnumber='ACC-001'", + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: {}, + }); + + await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE); + + const [, , path, body] = singleCall(); + expect(path).toBe("/accounts(accountnumber='ACC-001')"); + expect(body).toEqual({ name: 'Acme' }); + }); + + it('throws with the upsert-specific message when the body is empty', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'guid', + recordId: ROW_ID, + inputMode: 'json', + fieldsJson: '{}', + upsertOptions: {}, + }); + + await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow( + /Create or Update requires at least one field/, + ); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + // The empty body is rejected before any lookup metadata is fetched. + expect(resolveLookupFields).not.toHaveBeenCalled(); + }); + + it('throws naming the alternate-key parameter when it is empty', async () => { + withParams({ + entitySet: 'accounts', + identifierType: 'alternateKey', + alternateKey: ' ', + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: {}, + }); + + await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"alternateKey"/); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + }); + + it.each([ + "accountnumber='x\\..\\contacts(00000000-0000-0000-0000-000000000002'", + "accountnumber='ACC\t001'", + "accountnumber='ACC\n001'", + ])('rejects a URL-breaking alternate key before dispatching: %j', async (alternateKey) => { + withParams({ + entitySet: 'accounts', + identifierType: 'alternateKey', + alternateKey, + inputMode: 'json', + fieldsJson: '{"name":"Acme"}', + upsertOptions: {}, + }); + + await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"alternateKey"/); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + }); + }); + + describe('deleteRow', () => { + it('DELETEs the record path and returns a synthetic success payload', async () => { + withParams({ + entitySet: 'accounts', + recordId: ROW_ID, + deleteOptions: { partitionId: 'partition-1' }, + }); + + const result = (await deleteRow.execute(ctx, 0, CREDENTIAL_TYPE)) as IDataObject; + + expect(result).toEqual({ success: true, id: ROW_ID }); + const [, method, path, , qs, , credType] = singleCall(); + expect(method).toBe('DELETE'); + expect(path).toBe(`/accounts(${ROW_ID})`); + expect(qs).toEqual({ partitionId: 'partition-1' }); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('returns the synthetic payload regardless of the request response', async () => { + vi.mocked(dataverseApiRequest).mockResolvedValue({ unexpected: 'body' }); + withParams({ entitySet: 'accounts', recordId: ROW_ID, deleteOptions: {} }); + + const result = (await deleteRow.execute(ctx, 0, CREDENTIAL_TYPE)) as IDataObject; + + expect(result).toEqual({ success: true, id: ROW_ID }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Dataverse/test/shared.test.ts b/packages/nodes-base/nodes/Microsoft/Dataverse/test/shared.test.ts new file mode 100644 index 00000000000..fcf6d6c8f37 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Dataverse/test/shared.test.ts @@ -0,0 +1,426 @@ +// `{ name, value }` objects below are field-input fixtures, not node params. +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ + +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { mockDeep } from 'vitest-mock-extended'; + +import { dataverseApiRequest, dataverseApiRequestAllItems } from '../GenericFunctions'; +import { + applyPartitionIdToQuery, + assertNonEmptyBody, + assertNonEmptyRecordId, + assertValidAlternateKey, + assertValidEntitySet, + assertValidRecordId, + buildODataQs, + buildPreferHeader, + buildRecordPath, + executeRequest, + normalizeEntitySet, + parseItemInput, +} from '../operations/shared'; + +vi.mock('../GenericFunctions', () => ({ + dataverseApiRequest: vi.fn(), + dataverseApiRequestAllItems: vi.fn(), +})); + +const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api'; + +const node: INode = { + id: 'test-node', + name: 'Microsoft Dataverse', + type: 'n8n-nodes-base.microsoftDataverse', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +describe('Microsoft Dataverse operations/shared', () => { + let ctx: ReturnType>; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(node); + }); + + describe('buildODataQs', () => { + it('maps known option keys to their $-prefixed OData equivalents', () => { + expect(buildODataQs({ select: 'name', filter: 'statecode eq 0' })).toEqual({ + $select: 'name', + $filter: 'statecode eq 0', + }); + }); + + it('drops undefined, null, and empty-string values', () => { + expect( + buildODataQs({ select: '', filter: undefined, orderby: null as unknown as string }), + ).toEqual({}); + }); + + it('joins array values with commas and drops empty arrays', () => { + expect(buildODataQs({ select: ['name', 'accountid'], expand: [] })).toEqual({ + $select: 'name,accountid', + }); + }); + + it('preserves numeric values so $top works', () => { + expect(buildODataQs({ top: 25 })).toEqual({ $top: 25 }); + }); + }); + + describe('parseItemInput', () => { + it('returns a parsed object from a JSON string', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' ? 'json' : '{"name":"Acme"}', + ); + + expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme' }); + }); + + it('returns an object value as-is in JSON mode', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' ? 'json' : { name: 'Acme' }, + ); + + expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme' }); + }); + + it('returns an empty object for empty JSON input', () => { + ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : '')); + + expect(parseItemInput(ctx, 0)).toEqual({}); + }); + + it('rejects a JSON array with a descriptive error', () => { + ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : '[1,2]')); + + expect(() => parseItemInput(ctx, 0)).toThrow(/must be a JSON object, got array/); + }); + + it('rejects a JSON primitive with a descriptive error', () => { + ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : '42')); + + expect(() => parseItemInput(ctx, 0)).toThrow(/must be a JSON object, got number/); + }); + + it('throws NodeOperationError for malformed JSON', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' ? 'json' : '{not json}', + ); + + expect(() => parseItemInput(ctx, 0)).toThrow(NodeOperationError); + expect(() => parseItemInput(ctx, 0)).toThrow(/is not valid JSON/); + }); + + it('collects name/value pairs in fields mode', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' + ? 'fields' + : { + field: [ + { name: 'name', value: 'Acme' }, + { name: 'revenue', value: 100 }, + ], + }, + ); + + expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme', revenue: 100 }); + }); + + it('skips fields entries without a name', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' + ? 'fields' + : { + field: [ + { name: '', value: 'ignored' }, + { name: 'keep', value: 1 }, + ], + }, + ); + + expect(parseItemInput(ctx, 0)).toEqual({ keep: 1 }); + }); + + it('sanitizes unsafe dynamic field names in fields mode', () => { + ctx.getNodeParameter.mockImplementation((name) => + name === 'inputMode' + ? 'fields' + : { + field: [ + { name: 'before', value: 1 }, + { name: '__proto__', value: { polluted: true } }, + { name: 'constructor', value: 'dropped' }, + { name: 'after', value: 2 }, + ], + }, + ); + + const result = parseItemInput(ctx, 0); + + expect(result).toEqual({ before: 1, after: 2 }); + expect(({} as Record).polluted).toBeUndefined(); + }); + }); + + describe('buildPreferHeader', () => { + it('returns undefined when nothing is requested', () => { + expect(buildPreferHeader({})).toBeUndefined(); + }); + + it('emits return=representation', () => { + expect(buildPreferHeader({ returnRepresentation: true })).toBe('return=representation'); + }); + + it('emits the annotations token for full metadata', () => { + expect(buildPreferHeader({ includeFullMetadata: true })).toBe( + 'odata.include-annotations="*"', + ); + }); + + it('emits maxpagesize only for a positive page size', () => { + expect(buildPreferHeader({ maxPageSize: 100 })).toBe('odata.maxpagesize=100'); + expect(buildPreferHeader({ maxPageSize: 0 })).toBeUndefined(); + }); + + it('joins every requested part with commas', () => { + expect( + buildPreferHeader({ + returnRepresentation: true, + includeFullMetadata: true, + extra: ['custom=1'], + }), + ).toBe('return=representation,odata.include-annotations="*",custom=1'); + }); + }); + + describe('normalizeEntitySet', () => { + it('returns an empty string for non-string input', () => { + expect(normalizeEntitySet(undefined)).toBe(''); + expect(normalizeEntitySet(42)).toBe(''); + }); + + it('trims whitespace and strips leading/trailing slashes', () => { + expect(normalizeEntitySet(' /accounts/ ')).toBe('accounts'); + }); + + it('unwraps resource locator values', () => { + expect(normalizeEntitySet({ mode: 'list', value: 'contacts' })).toBe('contacts'); + }); + }); + + describe('assertValidEntitySet', () => { + it('returns a normalized table name from strings and resource locators', () => { + expect(assertValidEntitySet(ctx, 0, ' /accounts/ ')).toBe('accounts'); + expect(assertValidEntitySet(ctx, 0, 'accounts\r\n')).toBe('accounts'); + expect(assertValidEntitySet(ctx, 0, { mode: 'list', value: 'custom_table' })).toBe( + 'custom_table', + ); + }); + + it.each([ + '', + '123accounts', + 'account-name', + 'accounts/../contacts', + 'accounts\\..\\contacts', + 'accounts?query', + 'accounts#fragment', + 'account name', + 'accounts\tcontacts', + ])('rejects an invalid table name: %j', (entitySet) => { + expect(() => assertValidEntitySet(ctx, 0, entitySet)).toThrow(/"entitySet"/); + }); + }); + + describe('buildRecordPath', () => { + it('builds a GUID path', () => { + expect(buildRecordPath('accounts', '7c-guid')).toBe('/accounts(7c-guid)'); + }); + + it('normalizes the entity set and trims the id', () => { + expect(buildRecordPath('/accounts/', " accountnumber='ACC-001' ")).toBe( + "/accounts(accountnumber='ACC-001')", + ); + }); + }); + + describe('assertNonEmptyRecordId', () => { + it('returns the trimmed id when present', () => { + expect(assertNonEmptyRecordId(ctx, 0, ' abc ')).toBe('abc'); + expect(assertNonEmptyRecordId(ctx, 0, { mode: 'list', value: 'row-1' })).toBe('row-1'); + }); + + it('throws for whitespace-only, non-string, or empty ids', () => { + expect(() => assertNonEmptyRecordId(ctx, 0, ' ')).toThrow(NodeOperationError); + expect(() => assertNonEmptyRecordId(ctx, 0, null)).toThrow(/required/); + }); + + it('uses the provided parameter name in the message', () => { + expect(() => assertNonEmptyRecordId(ctx, 0, '', 'rowId')).toThrow(/"rowId"/); + }); + }); + + describe('assertValidRecordId', () => { + const guid = '00000000-0000-0000-0000-000000000001'; + + it('returns a valid GUID (trimmed, from a resource locator)', () => { + expect(assertValidRecordId(ctx, 0, ` ${guid} `)).toBe(guid); + expect(assertValidRecordId(ctx, 0, { mode: 'list', value: guid })).toBe(guid); + }); + + it('rejects a non-GUID value, naming the parameter', () => { + expect(() => assertValidRecordId(ctx, 0, 'abc-123')).toThrow(/must be a record GUID/); + expect(() => assertValidRecordId(ctx, 0, 'abc-123', 'rowId')).toThrow(/"rowId"/); + }); + + it('rejects a GUID carrying URL-breaking characters', () => { + expect(() => assertValidRecordId(ctx, 0, `${guid}?$select=x`)).toThrow( + /must be a record GUID/, + ); + expect(() => assertValidRecordId(ctx, 0, `${guid}#frag`)).toThrow(/must be a record GUID/); + }); + }); + + describe('assertValidAlternateKey', () => { + it('returns a predicate with quotes and commas untouched', () => { + expect(assertValidAlternateKey(ctx, 0, " accountnumber='ACC-001' ")).toBe( + "accountnumber='ACC-001'", + ); + expect(assertValidAlternateKey(ctx, 0, "key1='a',key2=123")).toBe("key1='a',key2=123"); + }); + + it('rejects URL delimiters and path separators, naming the parameter', () => { + expect(() => assertValidAlternateKey(ctx, 0, "name='a?b'")).toThrow(/"alternateKey"/); + expect(() => assertValidAlternateKey(ctx, 0, "name='a#b'")).toThrow(/must not contain/); + expect(() => assertValidAlternateKey(ctx, 0, "name='a/b'")).toThrow(/must not contain/); + expect(() => assertValidAlternateKey(ctx, 0, "name='a\\b'")).toThrow(/must not contain/); + }); + + it.each(["name='a\0b'", "name='a\tb'", "name='a\nb'"])( + 'rejects ASCII control characters in %j', + (predicate) => { + expect(() => assertValidAlternateKey(ctx, 0, predicate)).toThrow(/control characters/); + }, + ); + }); + + describe('assertNonEmptyBody', () => { + it('returns the body when it has at least one field', () => { + expect(assertNonEmptyBody(ctx, 0, { name: 'Acme' }, 'Add a New Row')).toEqual({ + name: 'Acme', + }); + }); + + it('throws for an empty body, naming the operation', () => { + expect(() => assertNonEmptyBody(ctx, 0, {}, 'Add a New Row')).toThrow( + /Add a New Row requires at least one field/, + ); + }); + }); + + describe('applyPartitionIdToQuery', () => { + it('adds a trimmed partitionId to the query string', () => { + expect(applyPartitionIdToQuery({}, ' p1 ')).toEqual({ partitionId: 'p1' }); + }); + + it('leaves the query string untouched for empty or non-string input', () => { + expect(applyPartitionIdToQuery({}, ' ')).toEqual({}); + expect(applyPartitionIdToQuery({}, undefined)).toEqual({}); + }); + }); + + describe('executeRequest', () => { + it('dispatches a single-shot request folding partitionId into the query', async () => { + vi.mocked(dataverseApiRequest).mockResolvedValue({ id: 'row-1' }); + + const result = await executeRequest(ctx, CREDENTIAL_TYPE, { + method: 'GET', + path: '/accounts', + options: { partitionId: 'p1', returnFullMetadata: true }, + }); + + expect(result).toEqual({ id: 'row-1' }); + expect(dataverseApiRequestAllItems).not.toHaveBeenCalled(); + const [, method, path, body, qs, headers] = vi.mocked(dataverseApiRequest).mock.calls[0]; + expect(method).toBe('GET'); + expect(path).toBe('/accounts'); + expect(body).toEqual({}); + expect(qs).toEqual({ partitionId: 'p1' }); + expect(headers?.Prefer).toBe('odata.include-annotations="*"'); + }); + + it('omits the Prefer header when no metadata or prefer options are set', async () => { + vi.mocked(dataverseApiRequest).mockResolvedValue({}); + + await executeRequest(ctx, CREDENTIAL_TYPE, { + method: 'GET', + path: '/accounts', + options: { returnFullMetadata: false }, + }); + + const [, , , , , headers] = vi.mocked(dataverseApiRequest).mock.calls[0]; + expect(headers?.Prefer).toBeUndefined(); + }); + + it('lets the computed Prefer override a caller-supplied extraHeaders.Prefer', async () => { + vi.mocked(dataverseApiRequest).mockResolvedValue({}); + + await executeRequest(ctx, CREDENTIAL_TYPE, { + method: 'POST', + path: '/accounts', + extraHeaders: { Prefer: 'caller-value' }, + prefer: { returnRepresentation: true }, + }); + + const [, , , , , headers] = vi.mocked(dataverseApiRequest).mock.calls[0]; + // Current behavior: computed Prefer wins (caller value is dropped). + expect(headers?.Prefer).toBe('return=representation'); + }); + + it('forwards the credential type on the single-shot path', async () => { + vi.mocked(dataverseApiRequest).mockResolvedValue({}); + + await executeRequest(ctx, CREDENTIAL_TYPE, { method: 'GET', path: '/accounts' }); + + const [, , , , , , credType] = vi.mocked(dataverseApiRequest).mock.calls[0]; + expect(credType).toBe(CREDENTIAL_TYPE); + }); + + it('dispatches a paged request when plan.paged is set', async () => { + vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([{ id: 1 }]); + + const result = await executeRequest(ctx, CREDENTIAL_TYPE, { + method: 'GET', + path: '/accounts', + paged: true, + limit: 10, + }); + + expect(result).toEqual([{ id: 1 }]); + expect(dataverseApiRequest).not.toHaveBeenCalled(); + const [, , , , limit] = vi.mocked(dataverseApiRequestAllItems).mock.calls[0]; + expect(limit).toBe(10); + }); + + it('folds partitionId and returnFullMetadata into the paged request', async () => { + vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([]); + + await executeRequest(ctx, CREDENTIAL_TYPE, { + method: 'GET', + path: '/accounts', + qs: { $select: 'name' }, + paged: true, + options: { partitionId: 'p1', returnFullMetadata: true }, + }); + + const [, , , qs, , credType, headers] = vi.mocked(dataverseApiRequestAllItems).mock.calls[0]; + expect(qs).toEqual({ $select: 'name', partitionId: 'p1' }); + expect(headers?.Prefer).toBe('odata.include-annotations="*"'); + expect(credType).toBe(CREDENTIAL_TYPE); + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index d86c5de066f..1cd27aca542 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -258,6 +258,7 @@ "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.js", "dist/credentials/MicrosoftAzureMonitorOAuth2Api.credentials.js", + "dist/credentials/MicrosoftDataverseOAuth2Api.credentials.js", "dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js", "dist/credentials/MicrosoftEntraOAuth2Api.credentials.js", "dist/credentials/MicrosoftEntraServicePrincipalApi.credentials.js", @@ -687,6 +688,7 @@ "dist/nodes/MessageBird/MessageBird.node.js", "dist/nodes/Metabase/Metabase.node.js", "dist/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.js", + "dist/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.js", "dist/nodes/Microsoft/Dynamics/MicrosoftDynamicsCrm.node.js", "dist/nodes/Microsoft/Entra/MicrosoftEntra.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js",