fix(core): Resolve Custom API Call support through inherited credential types (#35820)

This commit is contained in:
Csaba Tuncsik 2026-08-11 12:04:41 +02:00 committed by GitHub
parent b00fe2708f
commit 88efdb975e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 114 additions and 7 deletions

View File

@ -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;

View File

@ -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<string>()): 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);
});
}