From 88efdb975e9c124fb6977a0fec6bb8afc11fa081 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 11 Aug 2026 12:04:41 +0200 Subject: [PATCH] fix(core): Resolve Custom API Call support through inherited credential types (#35820) --- .../load-nodes-and-credentials.test.ts | 95 ++++++++++++++++++- .../cli/src/load-nodes-and-credentials.ts | 26 +++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index 4359380d6c0..27e4614c434 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -6,10 +6,12 @@ import { Service } from '@n8n/di'; import watcher from '@parcel/watcher'; import fs from 'fs/promises'; import { CUSTOM_NODES_PACKAGE_NAME, DirectoryLoader } from 'n8n-core'; -import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow'; +import type { ICredentialType, INodeProperties, INodeTypeDescription } from 'n8n-workflow'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_NAME } from '@/constants'; + import { LoadNodesAndCredentials } from '../load-nodes-and-credentials'; vi.mock('lodash/debounce', () => ({ default: (fn: () => void) => fn })); @@ -515,6 +517,97 @@ describe('LoadNodesAndCredentials', () => { }); }); + describe('injectCustomApiCallOptions', () => { + let instance: LoadNodesAndCredentials; + + const makeNode = (credentialName: string): INodeTypeDescription => ({ + name: 'n8n-nodes-base.test', + displayName: 'Test', + group: ['transform'], + description: 'Test node', + version: 1, + defaults: {}, + inputs: [], + outputs: ['main'], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [{ name: 'Page', value: 'page' }], + default: 'page', + }, + ], + credentials: [{ name: credentialName }], + }); + + const makeCredential = (name: string, extendsTypes?: string[]): ICredentialType => ({ + name, + displayName: name, + properties: [], + ...(extendsTypes ? { extends: extendsTypes } : {}), + }); + + const injectedOption = { name: CUSTOM_API_CALL_NAME, value: CUSTOM_API_CALL_KEY }; + + beforeEach(() => { + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); + }); + + it('should inject the option when a credential extends a base OAuth type directly', () => { + const node = makeNode('slackOAuth2Api'); + instance.types.nodes = [node]; + instance.types.credentials = [makeCredential('slackOAuth2Api', ['oAuth2Api'])]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectCustomApiCallOptions(); + + expect(node.properties[0].options).toEqual([{ name: 'Page', value: 'page' }, injectedOption]); + }); + + it('should inject the option when a credential reaches a base OAuth type through an intermediate', () => { + const node = makeNode('confluenceCloudOAuth2Api'); + instance.types.nodes = [node]; + instance.types.credentials = [ + makeCredential('confluenceCloudOAuth2Api', ['atlassianOAuth2Api']), + makeCredential('atlassianOAuth2Api', ['oAuth2Api']), + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectCustomApiCallOptions(); + + expect(node.properties[0].options).toEqual([{ name: 'Page', value: 'page' }, injectedOption]); + }); + + it('should not inject the option when the extends chain never reaches a base OAuth type', () => { + const node = makeNode('someApi'); + instance.types.nodes = [node]; + instance.types.credentials = [ + makeCredential('someApi', ['intermediateApi']), + makeCredential('intermediateApi'), + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectCustomApiCallOptions(); + + expect(node.properties[0].options).toEqual([{ name: 'Page', value: 'page' }]); + }); + + it('should not loop on a cyclic extends chain', () => { + const node = makeNode('cyclicApi'); + instance.types.nodes = [node]; + instance.types.credentials = [ + makeCredential('cyclicApi', ['otherCyclicApi']), + makeCredential('otherCyclicApi', ['cyclicApi']), + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectCustomApiCallOptions(); + + expect(node.properties[0].options).toEqual([{ name: 'Page', value: 'page' }]); + }); + }); + describe('setupHotReload', () => { let instance: LoadNodesAndCredentials; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 3c2f4f48ae9..e1783da3fb6 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -351,12 +351,26 @@ export class LoadNodesAndCredentials { } if (credType.authenticate !== undefined) return true; - return ( - Array.isArray(credType.extends) && - credType.extends.some((parentType) => - ['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType), - ) - ); + return this.extendsProxyAuthBaseType(credType); + }); + } + + /** + * Whether a credential type reaches one of the OAuth base types through its + * `extends` chain. Walks the chain transitively (cycle-guarded), since OAuth + * credentials often extend a vendor intermediate (e.g. `atlassianOAuth2Api`) + * rather than a base type directly. + */ + private extendsProxyAuthBaseType(credType: ICredentialType, seen = new Set()): boolean { + if (!Array.isArray(credType.extends)) return false; + + return credType.extends.some((parentName) => { + if (['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentName)) return true; + if (seen.has(parentName)) return false; + seen.add(parentName); + + const parent = this.types.credentials.find((t) => t.name === parentName); + return parent !== undefined && this.extendsProxyAuthBaseType(parent, seen); }); }