diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 9ec6f3ba19c9b..3ad823d4d5089 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -225,7 +225,7 @@ export function populateMapperFields(fields: ReadonlyArray<[string, string]>) { getParameterInputByName(name).type(value); // Click on a parent to dismiss the pop up which hides the field below. - getParameterInputByName(name).parent().parent().parent().click('topLeft'); + getParameterInputByName(name).parent().parent().parent().parent().click('topLeft'); } } diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index ed901107ea60e..033753bc5c91e 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -62,12 +62,14 @@ describe('n8n Form Trigger', () => { getVisibleSelect().contains('Dropdown').click(); cy.contains('button', 'Add Field Option').click(); cy.contains('label', 'Field Options') + .parent() .parent() .nextAll() .find('[data-test-id="parameter-input-field"]') .eq(0) .type('Option 1'); cy.contains('label', 'Field Options') + .parent() .parent() .nextAll() .find('[data-test-id="parameter-input-field"]') diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts index d529ec2c259cc..716d253b8ca69 100644 --- a/cypress/e2e/48-subworkflow-inputs.cy.ts +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -90,8 +90,8 @@ describe('Sub-workflow creation and typed usage', () => { clickExecuteNode(); const expected = [ - ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], - ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'], + ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'], ]; assertOutputTableContent(expected); @@ -110,8 +110,8 @@ describe('Sub-workflow creation and typed usage', () => { clickExecuteNode(); const expected2 = [ - ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], - ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'], + ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'], ]; assertOutputTableContent(expected2); @@ -167,8 +167,8 @@ describe('Sub-workflow creation and typed usage', () => { ); assertOutputTableContent([ - ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], - ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], + ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'], + ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'], ]); clickExecuteNode(); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.test.ts new file mode 100644 index 0000000000000..fc67b6bb1709f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.test.ts @@ -0,0 +1,156 @@ +import { mock } from 'jest-mock-extended'; +import type { PostgresNodeCredentials } from 'n8n-nodes-base/nodes/Postgres/v2/helpers/interfaces'; +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { getPostgresDataSource } from './postgres'; + +describe('Postgres SSL settings', () => { + const credentials = mock({ + host: 'localhost', + port: 5432, + user: 'user', + password: 'password', + database: 'database', + }); + + test('ssl is disabled + allowUnauthorizedCerts is false', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'disable', + allowUnauthorizedCerts: false, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: false, + }); + }); + + test('ssl is disabled + allowUnauthorizedCerts is true', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'disable', + allowUnauthorizedCerts: true, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: false, + }); + }); + + test('ssl is disabled + allowUnauthorizedCerts is undefined', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'disable', + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: false, + }); + }); + + test('ssl is allow + allowUnauthorizedCerts is false', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'allow', + allowUnauthorizedCerts: false, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: true, + }); + }); + + test('ssl is allow + allowUnauthorizedCerts is true', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'allow', + allowUnauthorizedCerts: true, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: { rejectUnauthorized: false }, + }); + }); + + test('ssl is allow + allowUnauthorizedCerts is undefined', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'allow', + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: true, + }); + }); + + test('ssl is require + allowUnauthorizedCerts is false', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'require', + allowUnauthorizedCerts: false, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: true, + }); + }); + + test('ssl is require + allowUnauthorizedCerts is true', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'require', + allowUnauthorizedCerts: true, + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: { rejectUnauthorized: false }, + }); + }); + + test('ssl is require + allowUnauthorizedCerts is undefined', async () => { + const context = mock({ + getCredentials: jest.fn().mockReturnValue({ + ...credentials, + ssl: 'require', + }), + }); + + const dataSource = await getPostgresDataSource.call(context); + + expect(dataSource.options).toMatchObject({ + ssl: true, + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts index 31dda9ed72821..8654dcafbbd9d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts @@ -1,29 +1,23 @@ import { DataSource } from '@n8n/typeorm'; +import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces'; import { type IExecuteFunctions } from 'n8n-workflow'; +import type { TlsOptions } from 'tls'; export async function getPostgresDataSource(this: IExecuteFunctions): Promise { - const credentials = await this.getCredentials('postgres'); + const credentials = await this.getCredentials('postgres'); - const dataSource = new DataSource({ - type: 'postgres', - host: credentials.host as string, - port: credentials.port as number, - username: credentials.user as string, - password: credentials.password as string, - database: credentials.database as string, - }); - - if (credentials.allowUnauthorizedCerts === true) { - dataSource.setOptions({ - ssl: { - rejectUnauthorized: true, - }, - }); - } else { - dataSource.setOptions({ - ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), - }); + let ssl: TlsOptions | boolean = !['disable', undefined].includes(credentials.ssl); + if (credentials.allowUnauthorizedCerts && ssl) { + ssl = { rejectUnauthorized: false }; } - return dataSource; + return new DataSource({ + type: 'postgres', + host: credentials.host, + port: credentials.port, + username: credentials.user, + password: credentials.password, + database: credentials.database, + ssl, + }); } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts index 05ed1e619c2ac..346234c8d3831 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts @@ -237,6 +237,69 @@ describe('ToolHttpRequest', () => { }), ); }); + + it('should return the error when receiving text that contains a null character', async () => { + helpers.httpRequest.mockResolvedValue({ + body: 'Hello\0World', + headers: { + 'content-type': 'text/plain', + }, + }); + + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'method': + return 'GET'; + case 'url': + return 'https://httpbin.org/text/plain'; + case 'options': + return {}; + case 'placeholderDefinitions.values': + return []; + default: + return undefined; + } + }); + + const { response } = await httpTool.supplyData.call(executeFunctions, 0); + const res = await (response as N8nTool).invoke({}); + expect(helpers.httpRequest).toHaveBeenCalled(); + // Check that the returned string is formatted as an error message. + expect(res).toContain('error'); + expect(res).toContain('Binary data is not supported'); + }); + + it('should return the error when receiving a JSON response containing a null character', async () => { + // Provide a raw JSON string with a literal null character. + helpers.httpRequest.mockResolvedValue({ + body: '{"message":"hello\0world"}', + headers: { + 'content-type': 'application/json', + }, + }); + + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'method': + return 'GET'; + case 'url': + return 'https://httpbin.org/json'; + case 'options': + return {}; + case 'placeholderDefinitions.values': + return []; + default: + return undefined; + } + }); + + const { response } = await httpTool.supplyData.call(executeFunctions, 0); + const res = await (response as N8nTool).invoke({}); + expect(helpers.httpRequest).toHaveBeenCalled(); + // Check that the tool returns an error string rather than resolving to valid JSON. + expect(res).toContain('error'); + expect(res).toContain('Binary data is not supported'); + }); }); describe('Optimize response', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index f1d6dfd150483..0bd1b1a8a6ba7 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -5,7 +5,6 @@ import { JSDOM } from 'jsdom'; import get from 'lodash/get'; import set from 'lodash/set'; import unset from 'lodash/unset'; -import * as mime from 'mime-types'; import { getOAuth2AdditionalParameters } from 'n8n-nodes-base/dist/nodes/HttpRequest/GenericFunctions'; import type { IDataObject, @@ -146,6 +145,25 @@ const defaultOptimizer = (response: T) => { return String(response); }; +function isBinary(data: unknown) { + // Check if data is a Buffer + if (Buffer.isBuffer(data)) { + return true; + } + + // If data is a string, assume it's text unless it contains null characters. + if (typeof data === 'string') { + // If the string contains a null character, it's likely binary. + if (data.includes('\0')) { + return true; + } + return false; + } + + // For any other type, assume it's not binary. + return false; +} + const htmlOptimizer = (ctx: ISupplyDataFunctions, itemIndex: number, maxLength: number) => { const cssSelector = ctx.getNodeParameter('cssSelector', itemIndex, '') as string; const onlyContent = ctx.getNodeParameter('onlyContent', itemIndex, false) as boolean; @@ -755,13 +773,8 @@ export const configureToolFunction = ( if (!response) { try { // Check if the response is binary data - if (fullResponse?.headers?.['content-type']) { - const contentType = fullResponse.headers['content-type'] as string; - const mimeType = contentType.split(';')[0].trim(); - - if (mime.charset(mimeType) !== 'UTF-8') { - throw new NodeOperationError(ctx.getNode(), 'Binary data is not supported'); - } + if (fullResponse.body && isBinary(fullResponse.body)) { + throw new NodeOperationError(ctx.getNode(), 'Binary data is not supported'); } response = optimizeResponse(fullResponse.body); 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 c5d88b1b25a51..ddc55ae25ed3a 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -107,7 +107,7 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [existingProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice + expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription expect(result.description.properties).toContainEqual(existingProp); }); @@ -121,9 +121,9 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [resourceProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(resourceProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(resourceProp); }); it('should handle nodes with operation property', () => { @@ -136,9 +136,9 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [operationProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(operationProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(operationProp); }); it('should handle nodes with both resource and operation properties', () => { @@ -158,17 +158,17 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [resourceProp, operationProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(resourceProp); - expect(result.description.properties[4]).toEqual(operationProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(resourceProp); + expect(result.description.properties[3]).toEqual(operationProp); }); it('should handle nodes with empty properties', () => { fullNodeWrapper.description.properties = []; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(2); - expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties).toHaveLength(1); + expect(result.description.properties[0].name).toBe('toolDescription'); }); it('should handle nodes with existing codex property', () => { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 8d0f050dee8f8..e33d736c1adb4 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -701,6 +701,7 @@ export class TelemetryEventRelay extends EventRelay { sharing_role: userRole, credential_type: null, is_managed: false, + ...TelemetryHelpers.resolveAIMetrics(workflow.nodes, this.nodeTypes), }; if (!manualExecEventProperties.node_graph_string) { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 4d6493d7ee7bc..dadf44c7ab69f 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -484,14 +484,6 @@ export class LoadNodesAndCredentials { placeholder: `e.g. ${item.description.description}`, }; - const noticeProp: INodeProperties = { - displayName: - "Use the expression {{ $fromAI('placeholder_name') }} for any data to be filled by the model", - name: 'notice', - type: 'notice', - default: '', - }; - item.description.properties.unshift(descProp); // If node has resource or operation we can determine pre-populate tool description based on it @@ -505,8 +497,6 @@ export class LoadNodesAndCredentials { }, }; } - - item.description.properties.unshift(noticeProp); } } diff --git a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap index fa1e46c8896ad..323ba7ae4f018 100644 --- a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap +++ b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap @@ -31,34 +31,39 @@ exports[`components > N8nCheckbox > should render with both child and label 1`] class="container" data-test-id="input-label" > - +
+ + + +
+ + + @@ -131,34 +136,39 @@ exports[`components > N8nCheckbox > should render with label 1`] = ` class="container" data-test-id="input-label" > - +
+ + + +
+ + + diff --git a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap index 7123cc6c5a231..949105aef3b93 100644 --- a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap +++ b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap @@ -33,41 +33,46 @@ exports[`FormBox > should render the component 1`] = ` class="container" data-test-id="name" > - +
+ + + +
+ + +
should render the component 1`] = ` class="container" data-test-id="email" > -
-
- - - -
- +
+ + + +
+ + +
should render the component 1`] = ` class="container" data-test-id="password" > -
-
- - - -
- +
+ + + +
+ + +
v-bind="$attrs" data-test-id="input-label" > -
-
- - - -
- +
+ + + +
+ + + diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts index e7ba9d420615b..21f8040699f66 100644 --- a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts @@ -72,7 +72,7 @@ describe('N8nSelectableList', () => { expect(wrapper.html()).toMatchSnapshot(); }); - it('renders disabled collection and clicks do not modify', async () => { + it('renders disabled collection without selectables', async () => { const wrapper = render(N8nSelectableList, { props: { modelValue: { @@ -87,20 +87,10 @@ describe('N8nSelectableList', () => { }, }); - expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); - expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); - expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); - expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument(); - - await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA')); - - expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); - expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument(); - - await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB')); - + expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument(); expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument(); expect(wrapper.html()).toMatchSnapshot(); }); diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue index e5ba985edf62b..de0133d2d0bba 100644 --- a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue @@ -59,7 +59,7 @@ function itemComparator(a: Item, b: Item) {