fix(API): Fixes how toJsonSchema creates dependant allOf checks when multiple fields depend on same field (#23114)

This commit is contained in:
Guillaume Jacquart
2025-12-15 14:56:18 +01:00
committed by GitHub
parent e3da10e20f
commit 957ebef54e
2 changed files with 258 additions and 9 deletions
@@ -6,6 +6,255 @@ import { toJsonSchema } from '../credentials.service';
describe('CredentialsService', () => {
describe('toJsonSchema', () => {
it('should create separate conditionals for different values of the same dependant field', () => {
// This test simulates the JWT auth credential scenario where
// multiple properties depend on the same field (keyType) but with different values
const properties: INodeProperties[] = [
{
name: 'keyType',
type: 'options',
options: [
{ value: 'passphrase', name: 'Passphrase' },
{ value: 'pemKey', name: 'PEM Key' },
],
displayName: 'Key Type',
default: 'passphrase',
},
{
name: 'secret',
type: 'string',
displayName: 'Secret',
default: '',
displayOptions: {
show: {
keyType: ['passphrase'],
},
},
},
{
name: 'privateKey',
type: 'string',
displayName: 'Private Key',
default: '',
displayOptions: {
show: {
keyType: ['pemKey'],
},
},
},
{
name: 'publicKey',
type: 'string',
displayName: 'Public Key',
default: '',
displayOptions: {
show: {
keyType: ['pemKey'],
},
},
},
];
const schema = toJsonSchema(properties);
const props = schema.properties as IDataObject;
expect(props).toBeDefined();
expect(props.keyType).toEqual({
type: 'string',
enum: ['passphrase', 'pemKey'],
});
// All conditional fields should not be globally required
expect(schema.required).not.toContain('secret');
expect(schema.required).not.toContain('privateKey');
expect(schema.required).not.toContain('publicKey');
// Should have 2 separate conditionals (one for each keyType value)
const allOf = schema.allOf as GenericValue[] | IDataObject[];
expect(Array.isArray(allOf)).toBe(true);
expect(allOf?.length).toBe(2);
// Find conditional for passphrase
const passphraseCondition = allOf?.find(
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'passphrase',
) as IDependency;
expect(passphraseCondition).toBeDefined();
expect(passphraseCondition.then?.allOf).toHaveLength(1);
expect(passphraseCondition.then?.allOf[0].required).toContain('secret');
expect(passphraseCondition.then?.allOf[0].required).not.toContain('privateKey');
expect(passphraseCondition.then?.allOf[0].required).not.toContain('publicKey');
// Find conditional for pemKey
const pemKeyCondition = allOf?.find(
(cond) => (cond as any).if?.properties?.keyType?.enum?.[0] === 'pemKey',
) as IDependency;
expect(pemKeyCondition).toBeDefined();
expect(pemKeyCondition.then?.allOf).toHaveLength(2);
expect(
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('privateKey')),
).toBe(true);
expect(
pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('publicKey')),
).toBe(true);
expect(pemKeyCondition.then?.allOf.some((req: any) => req.required?.includes('secret'))).toBe(
false,
);
});
it('should handle properties with no displayOptions as globally required', () => {
const properties: INodeProperties[] = [
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
{ name: 'domain', type: 'string', required: true, displayName: 'Domain', default: '' },
{
name: 'optionalField',
type: 'string',
required: false,
displayName: 'Optional',
default: '',
},
];
const schema = toJsonSchema(properties);
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'domain']));
expect(schema.required).not.toContain('optionalField');
expect(schema.allOf).toBeUndefined();
});
it('should handle mix of required and conditional properties', () => {
const properties: INodeProperties[] = [
{ name: 'apiKey', type: 'string', required: true, displayName: 'API Key', default: '' },
{
name: 'authType',
type: 'options',
required: true,
options: [
{ value: 'basic', name: 'Basic' },
{ value: 'oauth2', name: 'OAuth2' },
],
displayName: 'Auth Type',
default: 'basic',
},
{
name: 'username',
type: 'string',
displayName: 'Username',
default: '',
displayOptions: {
show: {
authType: ['basic'],
},
},
},
{
name: 'password',
type: 'string',
displayName: 'Password',
default: '',
displayOptions: {
show: {
authType: ['basic'],
},
},
},
{
name: 'clientId',
type: 'string',
displayName: 'Client ID',
default: '',
displayOptions: {
show: {
authType: ['oauth2'],
},
},
},
];
const schema = toJsonSchema(properties);
// apiKey and authType should be globally required
expect(schema.required).toEqual(expect.arrayContaining(['apiKey', 'authType']));
// Conditional fields should not be globally required
expect(schema.required).not.toContain('username');
expect(schema.required).not.toContain('password');
expect(schema.required).not.toContain('clientId');
// Should have 2 conditionals
const allOf = schema.allOf as GenericValue[] | IDataObject[];
expect(allOf?.length).toBe(2);
});
it('should handle properties with multiple options depending on same field', () => {
const properties: INodeProperties[] = [
{
name: 'operation',
type: 'options',
options: [
{ value: 'create', name: 'Create' },
{ value: 'update', name: 'Update' },
{ value: 'delete', name: 'Delete' },
],
displayName: 'Operation',
default: 'create',
},
{
name: 'createField',
type: 'string',
displayName: 'Create Field',
default: '',
displayOptions: {
show: {
operation: ['create'],
},
},
},
{
name: 'updateField',
type: 'string',
displayName: 'Update Field',
default: '',
displayOptions: {
show: {
operation: ['update'],
},
},
},
{
name: 'deleteField',
type: 'string',
displayName: 'Delete Field',
default: '',
displayOptions: {
show: {
operation: ['delete'],
},
},
},
];
const schema = toJsonSchema(properties);
// Should have 3 separate conditionals (one for each operation)
const allOf = schema.allOf as GenericValue[] | IDataObject[];
expect(allOf?.length).toBe(3);
// Verify each conditional is correct
const createCondition = allOf?.find(
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'create',
) as IDependency;
expect(createCondition?.then?.allOf[0].required).toContain('createField');
const updateCondition = allOf?.find(
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'update',
) as IDependency;
expect(updateCondition?.then?.allOf[0].required).toContain('updateField');
const deleteCondition = allOf?.find(
(cond) => (cond as any).if?.properties?.operation?.enum?.[0] === 'delete',
) as IDependency;
expect(deleteCondition?.then?.allOf[0].required).toContain('deleteField');
});
it('should add "false" displayOptions.show dependant value as allof condition', () => {
const properties: INodeProperties[] = [
{ name: 'field1', type: 'string', required: true, displayName: 'Field 1', default: '' },
@@ -215,11 +215,12 @@ export function toJsonSchema(properties: INodeProperties[]): IDataObject {
dependantValue = displayOptionsValues[0];
}
if (propertyRequiredDependencies[dependantName] === undefined) {
propertyRequiredDependencies[dependantName] = {};
}
// Create a unique key for each dependant name and value combination
// so that if multiple properties depend on the same property but different values
// they get their own if-then-else block
const dependencyKey = `${dependantName}:${JSON.stringify(dependantValue)}`;
if (!resolveProperties.includes(dependantName)) {
if (!resolveProperties.includes(dependencyKey)) {
let conditionalValue;
if (typeof dependantValue === 'object' && dependantValue._cnd) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -285,7 +286,7 @@ export function toJsonSchema(properties: INodeProperties[]): IDataObject {
enum: [dependantValue],
};
}
propertyRequiredDependencies[dependantName] = {
propertyRequiredDependencies[dependencyKey] = {
if: {
properties: {
[dependantName]: conditionalValue,
@@ -298,14 +299,13 @@ export function toJsonSchema(properties: INodeProperties[]): IDataObject {
allOf: [],
},
};
resolveProperties.push(dependencyKey);
}
propertyRequiredDependencies[dependantName].then?.allOf.push({ required: [property.name] });
propertyRequiredDependencies[dependantName].else?.allOf.push({
propertyRequiredDependencies[dependencyKey].then?.allOf.push({ required: [property.name] });
propertyRequiredDependencies[dependencyKey].else?.allOf.push({
not: { required: [property.name] },
});
resolveProperties.push(dependantName);
// remove global required
requiredFields = requiredFields.filter((field) => field !== property.name);
}