Merge branch 'master' of https://github.com/n8n-io/n8n into node-2015-google-calendar-confusing-errors

This commit is contained in:
Michael Kret 2025-01-06 09:13:16 +02:00
commit d187b11044
74 changed files with 1976 additions and 615 deletions

View file

@ -115,6 +115,8 @@ describe('Data mapping', () => {
}); });
it('maps expressions from json view', () => { it('maps expressions from json view', () => {
// ADO-3063 - followup to make this viewport global
cy.viewport('macbook-16');
cy.fixture('Test_workflow_3.json').then((data) => { cy.fixture('Test_workflow_3.json').then((data) => {
cy.get('body').paste(JSON.stringify(data)); cy.get('body').paste(JSON.stringify(data));
}); });
@ -123,17 +125,17 @@ describe('Data mapping', () => {
workflowPage.actions.openNode('Set'); workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('JSON'); ndv.actions.switchInputMode('JSON');
ndv.getters.inputDataContainer().should('exist');
ndv.getters ndv.getters
.inputDataContainer() .inputDataContainer()
.should('exist')
.find('.json-data') .find('.json-data')
.should( .should(
'have.text', 'have.text',
'[{"input": [{"count": 0,"with space": "!!","with.dot": "!!","with"quotes": "!!"}]},{"input": [{"count": 1}]}]', '[{"input": [{"count": 0,"with space": "!!","with.dot": "!!","with"quotes": "!!"}]},{"input": [{"count": 1}]}]',
) );
.find('span')
.contains('"count"') ndv.getters.inputDataContainer().find('span').contains('"count"').realMouseDown();
.realMouseDown();
ndv.actions.mapToParameter('value'); ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}'); ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');

View file

@ -21,6 +21,10 @@ export { ForgotPasswordRequestDto } from './password-reset/forgot-password-reque
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto'; export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto'; export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';
export { SamlToggleDto } from './saml/saml-toggle.dto';
export { PasswordUpdateRequestDto } from './user/password-update-request.dto'; export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
export { RoleChangeRequestDto } from './user/role-change-request.dto'; export { RoleChangeRequestDto } from './user/role-change-request.dto';
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto'; export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
@ -31,3 +35,5 @@ export { CommunityRegisteredRequestDto } from './license/community-registered-re
export { VariableListRequestDto } from './variables/variables-list-request.dto'; export { VariableListRequestDto } from './variables/variables-list-request.dto';
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto'; export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';

View file

@ -0,0 +1,155 @@
import { SamlPreferences } from '../saml-preferences.dto';
describe('SamlPreferences', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid minimal configuration',
request: {
mapping: {
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
loginEnabled: true,
loginLabel: 'Login with SAML',
},
},
{
name: 'valid full configuration',
request: {
mapping: {
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
ignoreSSL: true,
loginBinding: 'post',
loginEnabled: true,
loginLabel: 'Login with SAML',
authnRequestsSigned: true,
wantAssertionsSigned: true,
wantMessageSigned: true,
acsBinding: 'redirect',
signatureConfig: {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
},
relayState: 'https://example.com/relay',
},
},
])('should validate $name', ({ request }) => {
const result = SamlPreferences.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid loginBinding',
request: {
loginBinding: 'invalid',
},
expectedErrorPath: ['loginBinding'],
},
{
name: 'invalid acsBinding',
request: {
acsBinding: 'invalid',
},
expectedErrorPath: ['acsBinding'],
},
{
name: 'invalid signatureConfig location action',
request: {
signatureConfig: {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'invalid',
},
},
},
expectedErrorPath: ['signatureConfig', 'location', 'action'],
},
{
name: 'missing signatureConfig location reference',
request: {
signatureConfig: {
prefix: 'ds',
location: {
action: 'after',
},
},
},
expectedErrorPath: ['signatureConfig', 'location', 'reference'],
},
{
name: 'invalid mapping email',
request: {
mapping: {
email: 123,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
},
expectedErrorPath: ['mapping', 'email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = SamlPreferences.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
describe('Edge cases', () => {
test('should handle optional fields correctly', () => {
const validRequest = {
mapping: undefined,
metadata: undefined,
metadataUrl: undefined,
loginEnabled: undefined,
loginLabel: undefined,
};
const result = SamlPreferences.safeParse(validRequest);
expect(result.success).toBe(true);
});
test('should handle default values correctly', () => {
const validRequest = {};
const result = SamlPreferences.safeParse(validRequest);
expect(result.success).toBe(true);
expect(result.data?.ignoreSSL).toBe(false);
expect(result.data?.loginBinding).toBe('redirect');
expect(result.data?.authnRequestsSigned).toBe(false);
expect(result.data?.wantAssertionsSigned).toBe(true);
expect(result.data?.wantMessageSigned).toBe(true);
expect(result.data?.acsBinding).toBe('post');
expect(result.data?.signatureConfig).toEqual({
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
});
expect(result.data?.relayState).toBe('');
});
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class SamlAcsDto extends Z.class({
RelayState: z.string().optional(),
}) {}

View file

@ -0,0 +1,50 @@
import { z } from 'zod';
import { Z } from 'zod-class';
const SamlLoginBindingSchema = z.enum(['redirect', 'post']);
/** Schema for configuring the signature in SAML requests/responses. */
const SignatureConfigSchema = z.object({
prefix: z.string().default('ds'),
location: z.object({
reference: z.string(),
action: z.enum(['before', 'after', 'prepend', 'append']),
}),
});
export class SamlPreferences extends Z.class({
/** Mapping of SAML attributes to user fields. */
mapping: z
.object({
email: z.string(),
firstName: z.string(),
lastName: z.string(),
userPrincipalName: z.string(),
})
.optional(),
/** SAML metadata in XML format. */
metadata: z.string().optional(),
metadataUrl: z.string().optional(),
ignoreSSL: z.boolean().default(false),
loginBinding: SamlLoginBindingSchema.default('redirect'),
/** Whether SAML login is enabled. */
loginEnabled: z.boolean().optional(),
/** Label for the SAML login button. on the Auth screen */
loginLabel: z.string().optional(),
authnRequestsSigned: z.boolean().default(false),
wantAssertionsSigned: z.boolean().default(true),
wantMessageSigned: z.boolean().default(true),
acsBinding: SamlLoginBindingSchema.default('post'),
signatureConfig: SignatureConfigSchema.default({
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
}),
relayState: z.string().default(''),
}) {}

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class SamlToggleDto extends Z.class({
loginEnabled: z.boolean(),
}) {}

View file

@ -0,0 +1,63 @@
import { ImportWorkflowFromUrlDto } from '../import-workflow-from-url.dto';
describe('ImportWorkflowFromUrlDto', () => {
describe('Valid requests', () => {
test('should validate $name', () => {
const result = ImportWorkflowFromUrlDto.safeParse({
url: 'https://example.com/workflow.json',
});
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid URL (not ending with .json)',
url: 'https://example.com/workflow',
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (missing protocol)',
url: 'example.com/workflow.json',
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (not a URL)',
url: 'not-a-url',
expectedErrorPath: ['url'],
},
{
name: 'missing URL',
url: undefined,
expectedErrorPath: ['url'],
},
{
name: 'null URL',
url: null,
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (ends with .json but not a valid URL)',
url: 'not-a-url.json',
expectedErrorPath: ['url'],
},
{
name: 'valid URL with query parameters',
url: 'https://example.com/workflow.json?param=value',
},
{
name: 'valid URL with fragments',
url: 'https://example.com/workflow.json#section',
},
])('should fail validation for $name', ({ url, expectedErrorPath }) => {
const result = ImportWorkflowFromUrlDto.safeParse({ url });
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ImportWorkflowFromUrlDto extends Z.class({
url: z.string().url().endsWith('.json'),
}) {}

View file

@ -9,6 +9,7 @@ export const LOG_SCOPES = [
'multi-main-setup', 'multi-main-setup',
'pruning', 'pruning',
'pubsub', 'pubsub',
'push',
'redis', 'redis',
'scaling', 'scaling',
'waiting-executions', 'waiting-executions',
@ -70,10 +71,13 @@ export class LoggingConfig {
* - `external-secrets` * - `external-secrets`
* - `license` * - `license`
* - `multi-main-setup` * - `multi-main-setup`
* - `pruning`
* - `pubsub` * - `pubsub`
* - `push`
* - `redis` * - `redis`
* - `scaling` * - `scaling`
* - `waiting-executions` * - `waiting-executions`
* - `task-runner`
* *
* @example * @example
* `N8N_LOG_SCOPES=license` * `N8N_LOG_SCOPES=license`

View file

@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
}; };
export const promptTypeOptions: INodeProperties = { export const promptTypeOptions: INodeProperties = {
displayName: 'Prompt Source (User Message)', displayName: 'Source for Prompt (User Message)',
name: 'promptType', name: 'promptType',
type: 'options', type: 'options',
options: [ options: [
@ -98,7 +98,7 @@ export const textInput: INodeProperties = {
}; };
export const textFromPreviousNode: INodeProperties = { export const textFromPreviousNode: INodeProperties = {
displayName: 'Text From Previous Node', displayName: 'Prompt (User Message)',
name: 'text', name: 'text',
type: 'string', type: 'string',
required: true, required: true,

View file

@ -1,4 +1,3 @@
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import type { IBinaryData } from 'n8n-workflow'; import type { IBinaryData } from 'n8n-workflow';
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
@ -18,11 +17,12 @@ import {
type DataRequestResponse, type DataRequestResponse,
type InputDataChunkDefinition, type InputDataChunkDefinition,
} from '@/runner-types'; } from '@/runner-types';
import type { Task } from '@/task-runner'; import type { TaskParams } from '@/task-runner';
import { import {
newDataRequestResponse, newDataRequestResponse,
newTaskWithSettings, newTaskParamsWithSettings,
newTaskState,
withPairedItem, withPairedItem,
wrapIntoJson, wrapIntoJson,
} from './test-data'; } from './test-data';
@ -64,12 +64,12 @@ describe('JsTaskRunner', () => {
taskData, taskData,
runner = defaultTaskRunner, runner = defaultTaskRunner,
}: { }: {
task: Task<JSExecSettings>; task: TaskParams<JSExecSettings>;
taskData: DataRequestResponse; taskData: DataRequestResponse;
runner?: JsTaskRunner; runner?: JsTaskRunner;
}) => { }) => {
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData); jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
return await runner.executeTask(task, mock<AbortSignal>()); return await runner.executeTask(task, new AbortController().signal);
}; };
afterEach(() => { afterEach(() => {
@ -88,7 +88,7 @@ describe('JsTaskRunner', () => {
runner?: JsTaskRunner; runner?: JsTaskRunner;
}) => { }) => {
return await execTaskWithParams({ return await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code, code,
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
...settings, ...settings,
@ -112,7 +112,7 @@ describe('JsTaskRunner', () => {
chunk?: InputDataChunkDefinition; chunk?: InputDataChunkDefinition;
}) => { }) => {
return await execTaskWithParams({ return await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code, code,
nodeMode: 'runOnceForEachItem', nodeMode: 'runOnceForEachItem',
chunk, chunk,
@ -128,7 +128,7 @@ describe('JsTaskRunner', () => {
'should make an rpc call for console log in %s mode', 'should make an rpc call for console log in %s mode',
async (nodeMode) => { async (nodeMode) => {
jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const task = newTaskWithSettings({ const task = newTaskParamsWithSettings({
code: "console.log('Hello', 'world!'); return {}", code: "console.log('Hello', 'world!'); return {}",
nodeMode, nodeMode,
}); });
@ -146,7 +146,7 @@ describe('JsTaskRunner', () => {
); );
it('should not throw when using unsupported console methods', async () => { it('should not throw when using unsupported console methods', async () => {
const task = newTaskWithSettings({ const task = newTaskParamsWithSettings({
code: ` code: `
console.warn('test'); console.warn('test');
console.error('test'); console.error('test');
@ -176,7 +176,7 @@ describe('JsTaskRunner', () => {
}); });
it('should not throw when trying to log the context object', async () => { it('should not throw when trying to log the context object', async () => {
const task = newTaskWithSettings({ const task = newTaskParamsWithSettings({
code: ` code: `
console.log(this); console.log(this);
return {json: {}} return {json: {}}
@ -195,7 +195,7 @@ describe('JsTaskRunner', () => {
it('should log the context object as [[ExecutionContext]]', async () => { it('should log the context object as [[ExecutionContext]]', async () => {
const rpcCallSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); const rpcCallSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const task = newTaskWithSettings({ const task = newTaskParamsWithSettings({
code: ` code: `
console.log(this); console.log(this);
return {json: {}} return {json: {}}
@ -336,7 +336,7 @@ describe('JsTaskRunner', () => {
describe('$env', () => { describe('$env', () => {
it('should have the env available in context when access has not been blocked', async () => { it('should have the env available in context when access has not been blocked', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $env.VAR1 }', code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -355,7 +355,7 @@ describe('JsTaskRunner', () => {
it('should be possible to access env if it has been blocked', async () => { it('should be possible to access env if it has been blocked', async () => {
await expect( await expect(
execTaskWithParams({ execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $env.VAR1 }', code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -372,7 +372,7 @@ describe('JsTaskRunner', () => {
it('should not be possible to iterate $env', async () => { it('should not be possible to iterate $env', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return Object.values($env).concat(Object.keys($env))', code: 'return Object.values($env).concat(Object.keys($env))',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -391,7 +391,7 @@ describe('JsTaskRunner', () => {
it("should not expose task runner's env variables even if no env state is received", async () => { it("should not expose task runner's env variables even if no env state is received", async () => {
process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679'; process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679';
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }', code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -412,7 +412,7 @@ describe('JsTaskRunner', () => {
}; };
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $now.toSeconds() }', code: 'return { val: $now.toSeconds() }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -429,7 +429,7 @@ describe('JsTaskRunner', () => {
}); });
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $now.toSeconds() }', code: 'return { val: $now.toSeconds() }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -444,7 +444,7 @@ describe('JsTaskRunner', () => {
describe("$getWorkflowStaticData('global')", () => { describe("$getWorkflowStaticData('global')", () => {
it('should have the global workflow static data available in runOnceForAllItems', async () => { it('should have the global workflow static data available in runOnceForAllItems', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $getWorkflowStaticData("global") }', code: 'return { val: $getWorkflowStaticData("global") }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -460,7 +460,7 @@ describe('JsTaskRunner', () => {
it('should have the global workflow static data available in runOnceForEachItem', async () => { it('should have the global workflow static data available in runOnceForEachItem', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $getWorkflowStaticData("global") }', code: 'return { val: $getWorkflowStaticData("global") }',
nodeMode: 'runOnceForEachItem', nodeMode: 'runOnceForEachItem',
}), }),
@ -480,7 +480,7 @@ describe('JsTaskRunner', () => {
"does not return static data if it hasn't been modified in %s", "does not return static data if it hasn't been modified in %s",
async (mode) => { async (mode) => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: ` code: `
const staticData = $getWorkflowStaticData("global"); const staticData = $getWorkflowStaticData("global");
return { val: staticData }; return { val: staticData };
@ -502,7 +502,7 @@ describe('JsTaskRunner', () => {
'returns the updated static data in %s', 'returns the updated static data in %s',
async (mode) => { async (mode) => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: ` code: `
const staticData = $getWorkflowStaticData("global"); const staticData = $getWorkflowStaticData("global");
staticData.newKey = 'newValue'; staticData.newKey = 'newValue';
@ -541,7 +541,7 @@ describe('JsTaskRunner', () => {
it('should have the node workflow static data available in runOnceForAllItems', async () => { it('should have the node workflow static data available in runOnceForAllItems', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $getWorkflowStaticData("node") }', code: 'return { val: $getWorkflowStaticData("node") }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -553,7 +553,7 @@ describe('JsTaskRunner', () => {
it('should have the node workflow static data available in runOnceForEachItem', async () => { it('should have the node workflow static data available in runOnceForEachItem', async () => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: $getWorkflowStaticData("node") }', code: 'return { val: $getWorkflowStaticData("node") }',
nodeMode: 'runOnceForEachItem', nodeMode: 'runOnceForEachItem',
}), }),
@ -569,7 +569,7 @@ describe('JsTaskRunner', () => {
"does not return static data if it hasn't been modified in %s", "does not return static data if it hasn't been modified in %s",
async (mode) => { async (mode) => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: ` code: `
const staticData = $getWorkflowStaticData("node"); const staticData = $getWorkflowStaticData("node");
return { val: staticData }; return { val: staticData };
@ -587,7 +587,7 @@ describe('JsTaskRunner', () => {
'returns the updated static data in %s', 'returns the updated static data in %s',
async (mode) => { async (mode) => {
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: ` code: `
const staticData = $getWorkflowStaticData("node"); const staticData = $getWorkflowStaticData("node");
staticData.newKey = 'newValue'; staticData.newKey = 'newValue';
@ -662,7 +662,7 @@ describe('JsTaskRunner', () => {
// Act // Act
await execTaskWithParams({ await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: `await ${group.invocation}; return []`, code: `await ${group.invocation}; return []`,
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -684,7 +684,7 @@ describe('JsTaskRunner', () => {
// Act // Act
await execTaskWithParams({ await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: `await ${group.invocation}; return {}`, code: `await ${group.invocation}; return {}`,
nodeMode: 'runOnceForEachItem', nodeMode: 'runOnceForEachItem',
}), }),
@ -725,7 +725,7 @@ describe('JsTaskRunner', () => {
it('should allow access to Node.js Buffers', async () => { it('should allow access to Node.js Buffers', async () => {
const outcomeAll = await execTaskWithParams({ const outcomeAll = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: Buffer.from("test-buffer").toString() }', code: 'return { val: Buffer.from("test-buffer").toString() }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
@ -737,7 +737,7 @@ describe('JsTaskRunner', () => {
expect(outcomeAll.result).toEqual([wrapIntoJson({ val: 'test-buffer' })]); expect(outcomeAll.result).toEqual([wrapIntoJson({ val: 'test-buffer' })]);
const outcomePer = await execTaskWithParams({ const outcomePer = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'return { val: Buffer.from("test-buffer").toString() }', code: 'return { val: Buffer.from("test-buffer").toString() }',
nodeMode: 'runOnceForEachItem', nodeMode: 'runOnceForEachItem',
}), }),
@ -1205,7 +1205,7 @@ describe('JsTaskRunner', () => {
async (nodeMode) => { async (nodeMode) => {
await expect( await expect(
execTaskWithParams({ execTaskWithParams({
task: newTaskWithSettings({ task: newTaskParamsWithSettings({
code: 'unknown', code: 'unknown',
nodeMode, nodeMode,
}), }),
@ -1218,12 +1218,13 @@ describe('JsTaskRunner', () => {
it('sends serializes an error correctly', async () => { it('sends serializes an error correctly', async () => {
const runner = createRunnerWithOpts({}); const runner = createRunnerWithOpts({});
const taskId = '1'; const taskId = '1';
const task = newTaskWithSettings({ const task = newTaskState(taskId);
const taskSettings: JSExecSettings = {
code: 'unknown; return []', code: 'unknown; return []',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
continueOnFail: false, continueOnFail: false,
workflowMode: 'manual', workflowMode: 'manual',
}); };
runner.runningTasks.set(taskId, task); runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {}); const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {});
@ -1232,7 +1233,7 @@ describe('JsTaskRunner', () => {
.spyOn(runner, 'requestData') .spyOn(runner, 'requestData')
.mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })])); .mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })]));
await runner.receivedSettings(taskId, task.settings); await runner.receivedSettings(taskId, taskSettings);
expect(sendSpy).toHaveBeenCalled(); expect(sendSpy).toHaveBeenCalled();
const calledWith = sendSpy.mock.calls[0][0] as string; const calledWith = sendSpy.mock.calls[0][0] as string;
@ -1304,11 +1305,7 @@ describe('JsTaskRunner', () => {
const emitSpy = jest.spyOn(runner, 'emit'); const emitSpy = jest.spyOn(runner, 'emit');
jest.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] }); jest.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] });
runner.runningTasks.set(taskId, { runner.runningTasks.set(taskId, newTaskState(taskId));
taskId,
active: true,
cancelled: false,
});
jest.advanceTimersByTime(idleTimeout * 1000 - 100); jest.advanceTimersByTime(idleTimeout * 1000 - 100);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
@ -1335,15 +1332,13 @@ describe('JsTaskRunner', () => {
const runner = createRunnerWithOpts({}, { idleTimeout }); const runner = createRunnerWithOpts({}, { idleTimeout });
const taskId = '123'; const taskId = '123';
const emitSpy = jest.spyOn(runner, 'emit'); const emitSpy = jest.spyOn(runner, 'emit');
const task = newTaskState(taskId);
runner.runningTasks.set(taskId, { runner.runningTasks.set(taskId, task);
taskId,
active: true,
cancelled: false,
});
jest.advanceTimersByTime(idleTimeout * 1000); jest.advanceTimersByTime(idleTimeout * 1000);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
task.cleanup();
}); });
}); });
}); });

View file

@ -1,6 +1,9 @@
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { newTaskState } from '@/js-task-runner/__tests__/test-data';
import { TimeoutError } from '@/js-task-runner/errors/timeout-error';
import { TaskRunner, type TaskRunnerOpts } from '@/task-runner'; import { TaskRunner, type TaskRunnerOpts } from '@/task-runner';
import type { TaskStatus } from '@/task-state';
class TestRunner extends TaskRunner {} class TestRunner extends TaskRunner {}
@ -154,11 +157,8 @@ describe('TestRunner', () => {
runner.onMessage({ runner.onMessage({
type: 'broker:runnerregistered', type: 'broker:runnerregistered',
}); });
runner.runningTasks.set('test-task', { const taskState = newTaskState('test-task');
taskId: 'test-task', runner.runningTasks.set('test-task', taskState);
active: true,
cancelled: false,
});
const sendSpy = jest.spyOn(runner, 'send'); const sendSpy = jest.spyOn(runner, 'send');
runner.sendOffers(); runner.sendOffers();
@ -174,6 +174,7 @@ describe('TestRunner', () => {
}, },
], ],
]); ]);
taskState.cleanup();
}); });
it('should delete stale offers and send new ones', () => { it('should delete stale offers and send new ones', () => {
@ -198,15 +199,45 @@ describe('TestRunner', () => {
}); });
describe('taskCancelled', () => { describe('taskCancelled', () => {
it('should reject pending requests when task is cancelled', () => { test.each<[TaskStatus, string]>([
const runner = newTestRunner(); ['aborting:cancelled', 'cancelled'],
['aborting:timeout', 'timeout'],
])('should not do anything if task status is %s', async (status, reason) => {
runner = newTestRunner();
const taskId = 'test-task'; const taskId = 'test-task';
runner.runningTasks.set(taskId, { const task = newTaskState(taskId);
taskId, task.status = status;
active: false,
cancelled: false, runner.runningTasks.set(taskId, task);
});
await runner.taskCancelled(taskId, reason);
expect(runner.runningTasks.size).toBe(1);
expect(task.status).toBe(status);
});
it('should delete task if task is waiting for settings when task is cancelled', async () => {
runner = newTestRunner();
const taskId = 'test-task';
const task = newTaskState(taskId);
const taskCleanupSpy = jest.spyOn(task, 'cleanup');
runner.runningTasks.set(taskId, task);
await runner.taskCancelled(taskId, 'test-reason');
expect(runner.runningTasks.size).toBe(0);
expect(taskCleanupSpy).toHaveBeenCalled();
});
it('should reject pending requests when task is cancelled', async () => {
runner = newTestRunner();
const taskId = 'test-task';
const task = newTaskState(taskId);
task.status = 'running';
runner.runningTasks.set(taskId, task);
const dataRequestReject = jest.fn(); const dataRequestReject = jest.fn();
const nodeTypesRequestReject = jest.fn(); const nodeTypesRequestReject = jest.fn();
@ -225,7 +256,71 @@ describe('TestRunner', () => {
reject: nodeTypesRequestReject, reject: nodeTypesRequestReject,
}); });
runner.taskCancelled(taskId, 'test-reason'); await runner.taskCancelled(taskId, 'test-reason');
expect(dataRequestReject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Task cancelled: test-reason',
}),
);
expect(nodeTypesRequestReject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Task cancelled: test-reason',
}),
);
expect(runner.dataRequests.size).toBe(0);
expect(runner.nodeTypesRequests.size).toBe(0);
});
});
describe('taskTimedOut', () => {
it('should error task if task is waiting for settings', async () => {
runner = newTestRunner();
const taskId = 'test-task';
const task = newTaskState(taskId);
task.status = 'waitingForSettings';
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner, 'send');
await runner.taskTimedOut(taskId);
expect(runner.runningTasks.size).toBe(0);
expect(sendSpy).toHaveBeenCalledWith({
type: 'runner:taskerror',
taskId,
error: expect.any(TimeoutError),
});
});
it('should reject pending requests when task is running', async () => {
runner = newTestRunner();
const taskId = 'test-task';
const task = newTaskState(taskId);
task.status = 'running';
runner.runningTasks.set(taskId, task);
const dataRequestReject = jest.fn();
const nodeTypesRequestReject = jest.fn();
runner.dataRequests.set('data-req', {
taskId,
requestId: 'data-req',
resolve: jest.fn(),
reject: dataRequestReject,
});
runner.nodeTypesRequests.set('node-req', {
taskId,
requestId: 'node-req',
resolve: jest.fn(),
reject: nodeTypesRequestReject,
});
await runner.taskCancelled(taskId, 'test-reason');
expect(dataRequestReject).toHaveBeenCalledWith( expect(dataRequestReject).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

View file

@ -4,22 +4,21 @@ import { nanoid } from 'nanoid';
import type { JSExecSettings } from '@/js-task-runner/js-task-runner'; import type { JSExecSettings } from '@/js-task-runner/js-task-runner';
import type { DataRequestResponse } from '@/runner-types'; import type { DataRequestResponse } from '@/runner-types';
import type { Task } from '@/task-runner'; import type { TaskParams } from '@/task-runner';
import { TaskState } from '@/task-state';
/** /**
* Creates a new task with the given settings * Creates a new task with the given settings
*/ */
export const newTaskWithSettings = ( export const newTaskParamsWithSettings = (
settings: Partial<JSExecSettings> & Pick<JSExecSettings, 'code' | 'nodeMode'>, settings: Partial<JSExecSettings> & Pick<JSExecSettings, 'code' | 'nodeMode'>,
): Task<JSExecSettings> => ({ ): TaskParams<JSExecSettings> => ({
taskId: '1', taskId: '1',
settings: { settings: {
workflowMode: 'manual', workflowMode: 'manual',
continueOnFail: false, continueOnFail: false,
...settings, ...settings,
}, },
active: true,
cancelled: false,
}); });
/** /**
@ -167,3 +166,13 @@ export const withPairedItem = (index: number, data: INodeExecutionData): INodeEx
item: index, item: index,
}, },
}); });
/**
* Creates a new task state with the given taskId
*/
export const newTaskState = (taskId: string) =>
new TaskState({
taskId,
timeoutInS: 60,
onTimeout: () => {},
});

View file

@ -23,15 +23,15 @@ import { runInNewContext, type Context } from 'node:vm';
import type { MainConfig } from '@/config/main-config'; import type { MainConfig } from '@/config/main-config';
import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error'; import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error';
import { import { EXPOSED_RPC_METHODS, UNSUPPORTED_HELPER_FUNCTIONS } from '@/runner-types';
EXPOSED_RPC_METHODS, import type {
UNSUPPORTED_HELPER_FUNCTIONS, DataRequestResponse,
type DataRequestResponse, InputDataChunkDefinition,
type InputDataChunkDefinition, PartialAdditionalData,
type PartialAdditionalData, TaskResultData,
type TaskResultData,
} from '@/runner-types'; } from '@/runner-types';
import { type Task, TaskRunner } from '@/task-runner'; import type { TaskParams } from '@/task-runner';
import { noOp, TaskRunner } from '@/task-runner';
import { BuiltInsParser } from './built-ins-parser/built-ins-parser'; import { BuiltInsParser } from './built-ins-parser/built-ins-parser';
import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state'; import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state';
@ -81,8 +81,6 @@ type CustomConsole = {
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
}; };
const noOp = () => {};
export class JsTaskRunner extends TaskRunner { export class JsTaskRunner extends TaskRunner {
private readonly requireResolver: RequireResolver; private readonly requireResolver: RequireResolver;
@ -107,8 +105,11 @@ export class JsTaskRunner extends TaskRunner {
}); });
} }
async executeTask(task: Task<JSExecSettings>, signal: AbortSignal): Promise<TaskResultData> { async executeTask(
const settings = task.settings; taskParams: TaskParams<JSExecSettings>,
abortSignal: AbortSignal,
): Promise<TaskResultData> {
const { taskId, settings } = taskParams;
a.ok(settings, 'JS Code not sent to runner'); a.ok(settings, 'JS Code not sent to runner');
this.validateTaskSettings(settings); this.validateTaskSettings(settings);
@ -119,13 +120,13 @@ export class JsTaskRunner extends TaskRunner {
: BuiltInsParserState.newNeedsAllDataState(); : BuiltInsParserState.newNeedsAllDataState();
const dataResponse = await this.requestData<DataRequestResponse>( const dataResponse = await this.requestData<DataRequestResponse>(
task.taskId, taskId,
neededBuiltIns.toDataRequestParams(settings.chunk), neededBuiltIns.toDataRequestParams(settings.chunk),
); );
const data = this.reconstructTaskData(dataResponse, settings.chunk); const data = this.reconstructTaskData(dataResponse, settings.chunk);
await this.requestNodeTypeIfNeeded(neededBuiltIns, data.workflow, task.taskId); await this.requestNodeTypeIfNeeded(neededBuiltIns, data.workflow, taskId);
const workflowParams = data.workflow; const workflowParams = data.workflow;
const workflow = new Workflow({ const workflow = new Workflow({
@ -137,8 +138,8 @@ export class JsTaskRunner extends TaskRunner {
const result = const result =
settings.nodeMode === 'runOnceForAllItems' settings.nodeMode === 'runOnceForAllItems'
? await this.runForAllItems(task.taskId, settings, data, workflow, signal) ? await this.runForAllItems(taskId, settings, data, workflow, abortSignal)
: await this.runForEachItem(task.taskId, settings, data, workflow, signal); : await this.runForEachItem(taskId, settings, data, workflow, abortSignal);
return { return {
result, result,

View file

@ -5,19 +5,14 @@ import { EventEmitter } from 'node:events';
import { type MessageEvent, WebSocket } from 'ws'; import { type MessageEvent, WebSocket } from 'ws';
import type { BaseRunnerConfig } from '@/config/base-runner-config'; import type { BaseRunnerConfig } from '@/config/base-runner-config';
import { TimeoutError } from '@/js-task-runner/errors/timeout-error';
import type { BrokerMessage, RunnerMessage } from '@/message-types'; import type { BrokerMessage, RunnerMessage } from '@/message-types';
import { TaskRunnerNodeTypes } from '@/node-types'; import { TaskRunnerNodeTypes } from '@/node-types';
import type { TaskResultData } from '@/runner-types'; import type { TaskResultData } from '@/runner-types';
import { TaskState } from '@/task-state';
import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error'; import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error';
export interface Task<T = unknown> {
taskId: string;
settings?: T;
active: boolean;
cancelled: boolean;
}
export interface TaskOffer { export interface TaskOffer {
offerId: string; offerId: string;
validUntil: bigint; validUntil: bigint;
@ -49,6 +44,14 @@ const OFFER_VALID_EXTRA_MS = 100;
/** Converts milliseconds to nanoseconds */ /** Converts milliseconds to nanoseconds */
const msToNs = (ms: number) => BigInt(ms * 1_000_000); const msToNs = (ms: number) => BigInt(ms * 1_000_000);
export const noOp = () => {};
/** Params the task receives when it is executed */
export interface TaskParams<T = unknown> {
taskId: string;
settings: T;
}
export interface TaskRunnerOpts extends BaseRunnerConfig { export interface TaskRunnerOpts extends BaseRunnerConfig {
taskType: string; taskType: string;
name?: string; name?: string;
@ -61,7 +64,7 @@ export abstract class TaskRunner extends EventEmitter {
canSendOffers = false; canSendOffers = false;
runningTasks: Map<Task['taskId'], Task> = new Map(); runningTasks: Map<TaskState['taskId'], TaskState> = new Map();
offerInterval: NodeJS.Timeout | undefined; offerInterval: NodeJS.Timeout | undefined;
@ -89,10 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
/** How long (in seconds) a runner may be idle for before exit. */ /** How long (in seconds) a runner may be idle for before exit. */
private readonly idleTimeout: number; private readonly idleTimeout: number;
protected taskCancellations = new Map<Task['taskId'], AbortController>();
constructor(opts: TaskRunnerOpts) { constructor(opts: TaskRunnerOpts) {
super(); super();
this.taskType = opts.taskType; this.taskType = opts.taskType;
this.name = opts.name ?? 'Node.js Task Runner SDK'; this.name = opts.name ?? 'Node.js Task Runner SDK';
this.maxConcurrency = opts.maxConcurrency; this.maxConcurrency = opts.maxConcurrency;
@ -219,7 +221,7 @@ export abstract class TaskRunner extends EventEmitter {
this.offerAccepted(message.offerId, message.taskId); this.offerAccepted(message.offerId, message.taskId);
break; break;
case 'broker:taskcancel': case 'broker:taskcancel':
this.taskCancelled(message.taskId, message.reason); void this.taskCancelled(message.taskId, message.reason);
break; break;
case 'broker:tasksettings': case 'broker:tasksettings':
void this.receivedSettings(message.taskId, message.settings); void this.receivedSettings(message.taskId, message.settings);
@ -284,11 +286,14 @@ export abstract class TaskRunner extends EventEmitter {
} }
this.resetIdleTimer(); this.resetIdleTimer();
this.runningTasks.set(taskId, { const taskState = new TaskState({
taskId, taskId,
active: false, timeoutInS: this.taskTimeout,
cancelled: false, onTimeout: () => {
void this.taskTimedOut(taskId);
},
}); });
this.runningTasks.set(taskId, taskState);
this.send({ this.send({
type: 'runner:taskaccepted', type: 'runner:taskaccepted',
@ -296,99 +301,103 @@ export abstract class TaskRunner extends EventEmitter {
}); });
} }
taskCancelled(taskId: string, reason: string) { async taskCancelled(taskId: string, reason: string) {
const task = this.runningTasks.get(taskId); const taskState = this.runningTasks.get(taskId);
if (!task) { if (!taskState) {
return; return;
} }
task.cancelled = true;
for (const [requestId, request] of this.dataRequests.entries()) { await taskState.caseOf({
if (request.taskId === taskId) { // If the cancelled task hasn't received settings yet, we can finish it
request.reject(new TaskCancelledError(reason)); waitingForSettings: () => this.finishTask(taskState),
this.dataRequests.delete(requestId);
}
}
for (const [requestId, request] of this.nodeTypesRequests.entries()) { // If the task has already timed out or is already cancelled, we can
if (request.taskId === taskId) { // ignore the cancellation
request.reject(new TaskCancelledError(reason)); 'aborting:timeout': noOp,
this.nodeTypesRequests.delete(requestId); 'aborting:cancelled': noOp,
}
}
const controller = this.taskCancellations.get(taskId); running: () => {
if (controller) { taskState.status = 'aborting:cancelled';
controller.abort(); taskState.abortController.abort('cancelled');
this.taskCancellations.delete(taskId); this.cancelTaskRequests(taskId, reason);
} },
});
if (!task.active) this.runningTasks.delete(taskId);
this.sendOffers();
} }
taskErrored(taskId: string, error: unknown) { async taskTimedOut(taskId: string) {
this.send({ const taskState = this.runningTasks.get(taskId);
type: 'runner:taskerror', if (!taskState) {
taskId, return;
error, }
});
this.runningTasks.delete(taskId);
this.sendOffers();
}
taskDone(taskId: string, data: RunnerMessage.ToBroker.TaskDone['data']) { await taskState.caseOf({
this.send({ // If we are still waiting for settings for the task, we can error the
type: 'runner:taskdone', // task immediately
taskId, waitingForSettings: () => {
data, try {
this.send({
type: 'runner:taskerror',
taskId,
error: new TimeoutError(this.taskTimeout),
});
} finally {
this.finishTask(taskState);
}
},
// This should never happen, the timeout timer should only fire once
'aborting:timeout': TaskState.throwUnexpectedTaskStatus,
// If we are currently executing the task, abort the execution and
// mark the task as timed out
running: () => {
taskState.status = 'aborting:timeout';
taskState.abortController.abort('timeout');
this.cancelTaskRequests(taskId, 'timeout');
},
// If the task is already cancelling, we can ignore the timeout
'aborting:cancelled': noOp,
}); });
this.runningTasks.delete(taskId);
this.sendOffers();
} }
async receivedSettings(taskId: string, settings: unknown) { async receivedSettings(taskId: string, settings: unknown) {
const task = this.runningTasks.get(taskId); const taskState = this.runningTasks.get(taskId);
if (!task) { if (!taskState) {
return;
}
if (task.cancelled) {
this.runningTasks.delete(taskId);
return; return;
} }
const controller = new AbortController(); await taskState.caseOf({
this.taskCancellations.set(taskId, controller); // These states should never happen, as they are handled already in
// the other lifecycle methods and the task should be removed from the
// running tasks
'aborting:cancelled': TaskState.throwUnexpectedTaskStatus,
'aborting:timeout': TaskState.throwUnexpectedTaskStatus,
running: TaskState.throwUnexpectedTaskStatus,
const taskTimeout = setTimeout(() => { waitingForSettings: async () => {
if (!task.cancelled) { taskState.status = 'running';
controller.abort();
this.taskCancellations.delete(taskId);
}
}, this.taskTimeout * 1_000);
task.settings = settings; await this.executeTask(
task.active = true; {
try { taskId,
const data = await this.executeTask(task, controller.signal); settings,
this.taskDone(taskId, data); },
} catch (error) { taskState.abortController.signal,
if (!task.cancelled) this.taskErrored(taskId, error); )
} finally { .then(async (data) => await this.taskExecutionSucceeded(taskState, data))
clearTimeout(taskTimeout); .catch(async (error) => await this.taskExecutionFailed(taskState, error));
this.taskCancellations.delete(taskId); },
this.resetIdleTimer(); });
}
} }
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
async executeTask(_task: Task, _signal: AbortSignal): Promise<TaskResultData> { async executeTask(_taskParams: TaskParams, _signal: AbortSignal): Promise<TaskResultData> {
throw new ApplicationError('Unimplemented'); throw new ApplicationError('Unimplemented');
} }
async requestNodeTypes<T = unknown>( async requestNodeTypes<T = unknown>(
taskId: Task['taskId'], taskId: TaskState['taskId'],
requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'], requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'],
) { ) {
const requestId = nanoid(); const requestId = nanoid();
@ -417,12 +426,12 @@ export abstract class TaskRunner extends EventEmitter {
} }
async requestData<T = unknown>( async requestData<T = unknown>(
taskId: Task['taskId'], taskId: TaskState['taskId'],
requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'], requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'],
): Promise<T> { ): Promise<T> {
const requestId = nanoid(); const requestId = nanoid();
const p = new Promise<T>((resolve, reject) => { const dataRequestPromise = new Promise<T>((resolve, reject) => {
this.dataRequests.set(requestId, { this.dataRequests.set(requestId, {
requestId, requestId,
taskId, taskId,
@ -439,7 +448,7 @@ export abstract class TaskRunner extends EventEmitter {
}); });
try { try {
return await p; return await dataRequestPromise;
} finally { } finally {
this.dataRequests.delete(requestId); this.dataRequests.delete(requestId);
} }
@ -527,4 +536,86 @@ export abstract class TaskRunner extends EventEmitter {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
} }
private async taskExecutionSucceeded(taskState: TaskState, data: TaskResultData) {
try {
const sendData = () => {
this.send({
type: 'runner:taskdone',
taskId: taskState.taskId,
data,
});
};
await taskState.caseOf({
waitingForSettings: TaskState.throwUnexpectedTaskStatus,
'aborting:cancelled': noOp,
// If the task timed out but we ended up reaching this point, we
// might as well send the data
'aborting:timeout': sendData,
running: sendData,
});
} finally {
this.finishTask(taskState);
}
}
private async taskExecutionFailed(taskState: TaskState, error: unknown) {
try {
const sendError = () => {
this.send({
type: 'runner:taskerror',
taskId: taskState.taskId,
error,
});
};
await taskState.caseOf({
waitingForSettings: TaskState.throwUnexpectedTaskStatus,
'aborting:cancelled': noOp,
'aborting:timeout': () => {
console.warn(`Task ${taskState.taskId} timed out`);
sendError();
},
running: sendError,
});
} finally {
this.finishTask(taskState);
}
}
/**
* Cancels all node type and data requests made by the given task
*/
private cancelTaskRequests(taskId: string, reason: string) {
for (const [requestId, request] of this.dataRequests.entries()) {
if (request.taskId === taskId) {
request.reject(new TaskCancelledError(reason));
this.dataRequests.delete(requestId);
}
}
for (const [requestId, request] of this.nodeTypesRequests.entries()) {
if (request.taskId === taskId) {
request.reject(new TaskCancelledError(reason));
this.nodeTypesRequests.delete(requestId);
}
}
}
/**
* Finishes task by removing it from the running tasks and sending new offers
*/
private finishTask(taskState: TaskState) {
taskState.cleanup();
this.runningTasks.delete(taskState.taskId);
this.sendOffers();
this.resetIdleTimer();
}
} }

View file

@ -0,0 +1,118 @@
import * as a from 'node:assert';
export type TaskStatus =
| 'waitingForSettings'
| 'running'
| 'aborting:cancelled'
| 'aborting:timeout';
export type TaskStateOpts = {
taskId: string;
timeoutInS: number;
onTimeout: () => void;
};
/**
* The state of a task. The task can be in one of the following states:
* - waitingForSettings: The task is waiting for settings from the broker
* - running: The task is currently running
* - aborting:cancelled: The task was canceled by the broker and is being aborted
* - aborting:timeout: The task took too long to complete and is being aborted
*
* The task is discarded once it reaches an end state.
*
* The class only holds the state, and does not have any logic.
*
* The task has the following lifecycle:
*
*
*
*
* broker:taskofferaccept : create task state
*
*
* broker:taskcancel / timeout
* waitingForSettings
*
*
* broker:tasksettings
*
*
*
* running aborting:timeout
* timeout
* - execute task - fire abort signal
*
*
* broker:taskcancel
* Task execution Task execution
* resolves / rejects resolves / rejects
*
*
* aborting:cancelled
*
* - fire abort signal
*
* Task execution
* resolves / rejects
*
*
*
*
*
*/
export class TaskState {
status: TaskStatus = 'waitingForSettings';
readonly taskId: string;
/** Controller for aborting the execution of the task */
readonly abortController = new AbortController();
/** Timeout timer for the task */
private timeoutTimer: NodeJS.Timeout | undefined;
constructor(opts: TaskStateOpts) {
this.taskId = opts.taskId;
this.timeoutTimer = setTimeout(opts.onTimeout, opts.timeoutInS * 1000);
}
/** Cleans up any resources before the task can be removed */
cleanup() {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = undefined;
}
/** Custom JSON serialization for the task state for logging purposes */
toJSON() {
return `[Task ${this.taskId} (${this.status})]`;
}
/**
* Executes the function matching the current task status
*
* @example
* ```ts
* taskState.caseOf({
* waitingForSettings: () => {...},
* running: () => {...},
* aborting:cancelled: () => {...},
* aborting:timeout: () => {...},
* });
* ```
*/
async caseOf(
conditions: Record<TaskStatus, (taskState: TaskState) => void | Promise<void> | never>,
) {
if (!conditions[this.status]) {
TaskState.throwUnexpectedTaskStatus(this);
}
return await conditions[this.status](this);
}
/** Throws an error that the task status is unexpected */
static throwUnexpectedTaskStatus = (taskState: TaskState) => {
a.fail(`Unexpected task status: ${JSON.stringify(taskState)}`);
};
}

View file

@ -1,6 +1,7 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import type { n8n } from 'n8n-core'; import type { n8n } from 'n8n-core';
import { jsonParse } from 'n8n-workflow'; import type { ITaskDataConnections } from 'n8n-workflow';
import { jsonParse, TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
import { resolve, join, dirname } from 'path'; import { resolve, join, dirname } from 'path';
const { NODE_ENV, E2E_TESTS } = process.env; const { NODE_ENV, E2E_TESTS } = process.env;
@ -161,6 +162,22 @@ export const ARTIFICIAL_TASK_DATA = {
], ],
}; };
/**
* Connections for an item standing in for a manual execution data item too
* large to be sent live via pubsub. This signals to the client to direct the
* user to the execution history.
*/
export const TRIMMED_TASK_DATA_CONNECTIONS: ITaskDataConnections = {
main: [
[
{
json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true },
pairedItem: undefined,
},
],
],
};
/** Lowest priority, meaning shut down happens after other groups */ /** Lowest priority, meaning shut down happens after other groups */
export const LOWEST_SHUTDOWN_PRIORITY = 0; export const LOWEST_SHUTDOWN_PRIORITY = 0;
export const DEFAULT_SHUTDOWN_PRIORITY = 100; export const DEFAULT_SHUTDOWN_PRIORITY = 100;

View file

@ -1,7 +1,8 @@
import type { GlobalConfig } from '@n8n/config'; import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow'; import type { INode, INodesGraphResult } from 'n8n-workflow';
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -28,6 +29,9 @@ describe('TelemetryEventRelay', () => {
mode: 'smtp', mode: 'smtp',
}, },
}, },
diagnostics: {
enabled: true,
},
endpoints: { endpoints: {
metrics: { metrics: {
enable: true, enable: true,
@ -1106,4 +1110,393 @@ describe('TelemetryEventRelay', () => {
}); });
}); });
}); });
describe('workflow post execute events', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockWorkflowBase = mock<IWorkflowBase>({
id: 'workflow123',
name: 'Test Workflow',
active: true,
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
parameters: {},
typeVersion: 1,
position: [100, 200],
},
],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
staticData: {},
settings: {},
});
it('should not track when workflow has no id', async () => {
const event: RelayEventMap['workflow-post-execute'] = {
workflow: { ...mockWorkflowBase, id: '' },
executionId: 'execution123',
userId: 'user123',
};
eventService.emit('workflow-post-execute', event);
expect(telemetry.trackWorkflowExecution).not.toHaveBeenCalled();
});
it('should not track when execution status is "waiting"', async () => {
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData: {
status: 'waiting',
data: { resultData: {} },
} as IRun,
};
eventService.emit('workflow-post-execute', event);
expect(telemetry.trackWorkflowExecution).not.toHaveBeenCalled();
});
it('should track successful workflow execution', async () => {
const runData = mock<IRun>({
finished: true,
status: 'success',
mode: 'manual',
data: { resultData: {} },
});
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData: runData as unknown as IRun,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
user_id: 'user123',
success: true,
is_manual: true,
execution_mode: 'manual',
}),
);
});
it('should call telemetry.track when manual node execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'sharee',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when manual node execution finished with canceled error message', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner');
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message canceled',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'canceled',
executionStatus: 'canceled',
sharing_role: 'owner',
error_message: 'Error message canceled',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message canceled',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when manual workflow execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner');
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {
Jira: [
{
data: { main: [[{ json: { headers: { origin: 'https://www.test.com' } } }]] },
},
],
},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as unknown as IRun;
const nodeGraph: INodesGraphResult = {
webhookNodeNames: ['Jira'],
nodeGraph: { node_types: [], node_connections: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual workflow exec finished',
expect.objectContaining({
webhook_domain: 'test.com',
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'owner',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
});
}); });

View file

@ -183,7 +183,7 @@ describe('ExecutionService', () => {
describe('scaling mode', () => { describe('scaling mode', () => {
describe('manual execution', () => { describe('manual execution', () => {
it('should delegate to regular mode in scaling mode', async () => { it('should stop a `running` execution in scaling mode', async () => {
/** /**
* Arrange * Arrange
*/ */
@ -197,6 +197,8 @@ describe('ExecutionService', () => {
concurrencyControl.has.mockReturnValue(false); concurrencyControl.has.mockReturnValue(false);
activeExecutions.has.mockReturnValue(true); activeExecutions.has.mockReturnValue(true);
waitTracker.has.mockReturnValue(false); waitTracker.has.mockReturnValue(false);
const job = mock<Job>({ data: { executionId: '123' } });
scalingService.findJobsByStatus.mockResolvedValue([job]);
executionRepository.stopDuringRun.mockResolvedValue(mock<IExecutionResponse>()); executionRepository.stopDuringRun.mockResolvedValue(mock<IExecutionResponse>());
// @ts-expect-error Private method // @ts-expect-error Private method
const stopInRegularModeSpy = jest.spyOn(executionService, 'stopInRegularMode'); const stopInRegularModeSpy = jest.spyOn(executionService, 'stopInRegularMode');
@ -209,7 +211,7 @@ describe('ExecutionService', () => {
/** /**
* Assert * Assert
*/ */
expect(stopInRegularModeSpy).toHaveBeenCalledWith(execution); expect(stopInRegularModeSpy).not.toHaveBeenCalled();
expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id);
expect(executionRepository.stopDuringRun).toHaveBeenCalledWith(execution); expect(executionRepository.stopDuringRun).toHaveBeenCalledWith(execution);

View file

@ -464,11 +464,6 @@ export class ExecutionService {
} }
private async stopInScalingMode(execution: IExecutionResponse) { private async stopInScalingMode(execution: IExecutionResponse) {
if (execution.mode === 'manual') {
// manual executions in scaling mode are processed by main
return await this.stopInRegularMode(execution);
}
if (this.activeExecutions.has(execution.id)) { if (this.activeExecutions.has(execution.id)) {
this.activeExecutions.stopExecution(execution.id); this.activeExecutions.stopExecution(execution.id);
} }

View file

@ -20,7 +20,7 @@ describe('Push', () => {
test('should validate pushRef on requests for websocket backend', () => { test('should validate pushRef on requests for websocket backend', () => {
config.set('push.backend', 'websocket'); config.set('push.backend', 'websocket');
const push = new Push(mock(), mock()); const push = new Push(mock(), mock(), mock());
const ws = mock<WebSocket>(); const ws = mock<WebSocket>();
const request = mock<WebSocketPushRequest>({ user, ws }); const request = mock<WebSocketPushRequest>({ user, ws });
request.query = { pushRef: '' }; request.query = { pushRef: '' };
@ -33,7 +33,7 @@ describe('Push', () => {
test('should validate pushRef on requests for SSE backend', () => { test('should validate pushRef on requests for SSE backend', () => {
config.set('push.backend', 'sse'); config.set('push.backend', 'sse');
const push = new Push(mock(), mock()); const push = new Push(mock(), mock(), mock());
const request = mock<SSEPushRequest>({ user, ws: undefined }); const request = mock<SSEPushRequest>({ user, ws: undefined });
request.query = { pushRef: '' }; request.query = { pushRef: '' };
expect(() => push.handleRequest(request, mock())).toThrow(BadRequestError); expect(() => push.handleRequest(request, mock())).toThrow(BadRequestError);

View file

@ -2,7 +2,8 @@ import type { PushMessage } from '@n8n/api-types';
import type { Application } from 'express'; import type { Application } from 'express';
import { ServerResponse } from 'http'; import { ServerResponse } from 'http';
import type { Server } from 'http'; import type { Server } from 'http';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings, Logger } from 'n8n-core';
import { deepCopy } from 'n8n-workflow';
import type { Socket } from 'net'; import type { Socket } from 'net';
import { Container, Service } from 'typedi'; import { Container, Service } from 'typedi';
import { parse as parseUrl } from 'url'; import { parse as parseUrl } from 'url';
@ -10,6 +11,7 @@ import { Server as WSServer } from 'ws';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
import config from '@/config'; import config from '@/config';
import { TRIMMED_TASK_DATA_CONNECTIONS } from '@/constants';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { OnShutdown } from '@/decorators/on-shutdown'; import { OnShutdown } from '@/decorators/on-shutdown';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -27,6 +29,12 @@ type PushEvents = {
const useWebSockets = config.getEnv('push.backend') === 'websocket'; const useWebSockets = config.getEnv('push.backend') === 'websocket';
/**
* Max allowed size of a push message in bytes. Events going through the pubsub
* channel are trimmed if exceeding this size.
*/
const MAX_PAYLOAD_SIZE_BYTES = 5 * 1024 * 1024; // 5 MiB
/** /**
* Push service for uni- or bi-directional communication with frontend clients. * Push service for uni- or bi-directional communication with frontend clients.
* Uses either server-sent events (SSE, unidirectional from backend --> frontend) * Uses either server-sent events (SSE, unidirectional from backend --> frontend)
@ -43,8 +51,10 @@ export class Push extends TypedEmitter<PushEvents> {
constructor( constructor(
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly publisher: Publisher, private readonly publisher: Publisher,
private readonly logger: Logger,
) { ) {
super(); super();
this.logger = this.logger.scoped('push');
if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg)); if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg));
} }
@ -85,18 +95,14 @@ export class Push extends TypedEmitter<PushEvents> {
this.backend.sendToAll(pushMsg); this.backend.sendToAll(pushMsg);
} }
/** Returns whether a given push ref is registered. */
hasPushRef(pushRef: string) {
return this.backend.hasPushRef(pushRef);
}
send(pushMsg: PushMessage, pushRef: string) { send(pushMsg: PushMessage, pushRef: string) {
/** if (this.shouldRelayViaPubSub(pushRef)) {
* Multi-main setup: In a manual webhook execution, the main process that this.relayViaPubSub(pushMsg, pushRef);
* handles a webhook might not be the same as the main process that created
* the webhook. If so, the handler process commands the creator process to
* relay the former's execution lifecycle events to the creator's frontend.
*/
if (this.instanceSettings.isMultiMain && !this.backend.hasPushRef(pushRef)) {
void this.publisher.publishCommand({
command: 'relay-execution-lifecycle-event',
payload: { ...pushMsg, pushRef },
});
return; return;
} }
@ -111,6 +117,66 @@ export class Push extends TypedEmitter<PushEvents> {
onShutdown() { onShutdown() {
this.backend.closeAllConnections(); this.backend.closeAllConnections();
} }
/**
* Whether to relay a push message via pubsub channel to other instances,
* instead of pushing the message directly to the frontend.
*
* This is needed in two scenarios:
*
* In scaling mode, in single- or multi-main setup, in a manual execution, a
* worker has no connection to a frontend and so relays to all mains lifecycle
* events for manual executions. Only the main who holds the session for the
* execution will push to the frontend who commissioned the execution.
*
* In scaling mode, in multi-main setup, in a manual webhook execution, if
* the main who handles a webhook is not the main who created the webhook,
* the handler main relays execution lifecycle events to all mains. Only
* the main who holds the session for the execution will push events to
* the frontend who commissioned the execution.
*/
private shouldRelayViaPubSub(pushRef: string) {
const { isWorker, isMultiMain } = this.instanceSettings;
return isWorker || (isMultiMain && !this.hasPushRef(pushRef));
}
/**
* Relay a push message via the `n8n.commands` pubsub channel,
* reducing the payload size if too large.
*
* See {@link shouldRelayViaPubSub} for more details.
*/
private relayViaPubSub(pushMsg: PushMessage, pushRef: string) {
const eventSizeBytes = new TextEncoder().encode(JSON.stringify(pushMsg.data)).length;
if (eventSizeBytes <= MAX_PAYLOAD_SIZE_BYTES) {
void this.publisher.publishCommand({
command: 'relay-execution-lifecycle-event',
payload: { ...pushMsg, pushRef },
});
return;
}
// too large for pubsub channel, trim it
const pushMsgCopy = deepCopy(pushMsg);
const toMb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(0);
const eventMb = toMb(eventSizeBytes);
const maxMb = toMb(MAX_PAYLOAD_SIZE_BYTES);
const { type } = pushMsgCopy;
this.logger.warn(`Size of "${type}" (${eventMb} MB) exceeds max size ${maxMb} MB. Trimming...`);
if (type === 'nodeExecuteAfter') pushMsgCopy.data.data.data = TRIMMED_TASK_DATA_CONNECTIONS;
else if (type === 'executionFinished') pushMsgCopy.data.rawData = ''; // prompt client to fetch from DB
void this.publisher.publishCommand({
command: 'relay-execution-lifecycle-event',
payload: { ...pushMsgCopy, pushRef },
});
}
} }
export const setupPushServer = (restEndpoint: string, server: Server, app: Application) => { export const setupPushServer = (restEndpoint: string, server: Server, app: Application) => {

View file

@ -19,6 +19,7 @@ describe('JobProcessor', () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
); );
const result = await jobProcessor.processJob(mock<Job>()); const result = await jobProcessor.processJob(mock<Job>());

View file

@ -11,7 +11,6 @@ import type { ExternalSecretsManager } from '@/external-secrets.ee/external-secr
import type { IWorkflowDb } from '@/interfaces'; import type { IWorkflowDb } from '@/interfaces';
import type { License } from '@/license'; import type { License } from '@/license';
import type { Push } from '@/push'; import type { Push } from '@/push';
import type { WebSocketPush } from '@/push/websocket.push';
import type { CommunityPackagesService } from '@/services/community-packages.service'; import type { CommunityPackagesService } from '@/services/community-packages.service';
import type { TestWebhooks } from '@/webhooks/test-webhooks'; import type { TestWebhooks } from '@/webhooks/test-webhooks';
@ -829,9 +828,7 @@ describe('PubSubHandler', () => {
flattedRunData: '[]', flattedRunData: '[]',
}; };
push.getBackend.mockReturnValue( push.hasPushRef.mockReturnValue(true);
mock<WebSocketPush>({ hasPushRef: jest.fn().mockReturnValue(true) }),
);
eventService.emit('relay-execution-lifecycle-event', { type, data, pushRef }); eventService.emit('relay-execution-lifecycle-event', { type, data, pushRef });
@ -858,9 +855,7 @@ describe('PubSubHandler', () => {
const workflowEntity = mock<IWorkflowDb>({ id: 'test-workflow-id' }); const workflowEntity = mock<IWorkflowDb>({ id: 'test-workflow-id' });
const pushRef = 'test-push-ref'; const pushRef = 'test-push-ref';
push.getBackend.mockReturnValue( push.hasPushRef.mockReturnValue(true);
mock<WebSocketPush>({ hasPushRef: jest.fn().mockReturnValue(true) }),
);
testWebhooks.toWorkflow.mockReturnValue(mock<Workflow>({ id: 'test-workflow-id' })); testWebhooks.toWorkflow.mockReturnValue(mock<Workflow>({ id: 'test-workflow-id' }));
eventService.emit('clear-test-webhooks', { webhookKey, workflowEntity, pushRef }); eventService.emit('clear-test-webhooks', { webhookKey, workflowEntity, pushRef });

View file

@ -1,6 +1,11 @@
import type { RunningJobSummary } from '@n8n/api-types'; import type { RunningJobSummary } from '@n8n/api-types';
import { ErrorReporter, InstanceSettings, WorkflowExecute, Logger } from 'n8n-core'; import { InstanceSettings, WorkflowExecute, ErrorReporter, Logger } from 'n8n-core';
import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; import type {
ExecutionStatus,
IExecuteResponsePromiseData,
IRun,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow';
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import { Service } from 'typedi'; import { Service } from 'typedi';
@ -8,6 +13,7 @@ import { Service } from 'typedi';
import config from '@/config'; import config from '@/config';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { ManualExecutionService } from '@/manual-execution.service';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
@ -34,6 +40,7 @@ export class JobProcessor {
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly manualExecutionService: ManualExecutionService,
) { ) {
this.logger = this.logger.scoped('scaling'); this.logger = this.logger.scoped('scaling');
} }
@ -115,13 +122,20 @@ export class JobProcessor {
executionTimeoutTimestamp, executionTimeoutTimestamp,
); );
const { pushRef } = job.data;
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(
execution.mode, execution.mode,
job.data.executionId, job.data.executionId,
execution.workflowData, execution.workflowData,
{ retryOf: execution.retryOf as string }, { retryOf: execution.retryOf as string, pushRef },
); );
if (pushRef) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
additionalData.sendDataToUI = WorkflowExecuteAdditionalData.sendDataToUI.bind({ pushRef });
}
additionalData.hooks.hookFunctions.sendResponse = [ additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => { async (response: IExecuteResponsePromiseData): Promise<void> => {
const msg: RespondToWebhookMessage = { const msg: RespondToWebhookMessage = {
@ -146,7 +160,31 @@ export class JobProcessor {
let workflowExecute: WorkflowExecute; let workflowExecute: WorkflowExecute;
let workflowRun: PCancelable<IRun>; let workflowRun: PCancelable<IRun>;
if (execution.data !== undefined) {
const { startData, resultData, manualData, isTestWebhook } = execution.data;
if (execution.mode === 'manual' && !isTestWebhook) {
const data: IWorkflowExecutionDataProcess = {
executionMode: execution.mode,
workflowData: execution.workflowData,
destinationNode: startData?.destinationNode,
startNodes: startData?.startNodes,
runData: resultData.runData,
pinData: resultData.pinData,
partialExecutionVersion: manualData?.partialExecutionVersion,
dirtyNodeNames: manualData?.dirtyNodeNames,
triggerToStartFrom: manualData?.triggerToStartFrom,
userId: manualData?.userId,
};
workflowRun = this.manualExecutionService.runManually(
data,
workflow,
additionalData,
executionId,
resultData.pinData,
);
} else if (execution.data !== undefined) {
workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data);
workflowRun = workflowExecute.processRunExecutionData(workflow); workflowRun = workflowExecute.processRunExecutionData(workflow);
} else { } else {

View file

@ -160,12 +160,12 @@ export class PubSubHandler {
'display-workflow-activation-error': async ({ workflowId, errorMessage }) => 'display-workflow-activation-error': async ({ workflowId, errorMessage }) =>
this.push.broadcast({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage } }), this.push.broadcast({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage } }),
'relay-execution-lifecycle-event': async ({ pushRef, ...pushMsg }) => { 'relay-execution-lifecycle-event': async ({ pushRef, ...pushMsg }) => {
if (!this.push.getBackend().hasPushRef(pushRef)) return; if (!this.push.hasPushRef(pushRef)) return;
this.push.send(pushMsg, pushRef); this.push.send(pushMsg, pushRef);
}, },
'clear-test-webhooks': async ({ webhookKey, workflowEntity, pushRef }) => { 'clear-test-webhooks': async ({ webhookKey, workflowEntity, pushRef }) => {
if (!this.push.getBackend().hasPushRef(pushRef)) return; if (!this.push.hasPushRef(pushRef)) return;
this.testWebhooks.clearTimeout(webhookKey); this.testWebhooks.clearTimeout(webhookKey);

View file

@ -12,6 +12,7 @@ export type JobId = Job['id'];
export type JobData = { export type JobData = {
executionId: string; executionId: string;
loadStaticData: boolean; loadStaticData: boolean;
pushRef?: string;
}; };
export type JobResult = { export type JobResult = {

View file

@ -4,7 +4,7 @@ import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.r
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { generateNanoId } from '@/databases/utils/generators'; import { generateNanoId } from '@/databases/utils/generators';
import * as helpers from '@/sso.ee/saml/saml-helpers'; import * as helpers from '@/sso.ee/saml/saml-helpers';
import type { SamlUserAttributes } from '@/sso.ee/saml/types/saml-user-attributes'; import type { SamlUserAttributes } from '@/sso.ee/saml/types';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
const userRepository = mockInstance(UserRepository); const userRepository = mockInstance(UserRepository);

View file

@ -1,11 +1,15 @@
import { Logger } from 'n8n-core'; import { mock } from 'jest-mock-extended';
import { mockInstance } from '@test/mocking'; import { SamlValidator } from '../saml-validator';
import { validateMetadata, validateResponse } from '../saml-validator';
describe('saml-validator', () => { describe('saml-validator', () => {
mockInstance(Logger); const validator = new SamlValidator(mock());
const VALID_CERTIFICATE =
'MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT';
beforeAll(async () => {
await validator.init();
});
describe('validateMetadata', () => { describe('validateMetadata', () => {
test('successfully validates metadata containing ws federation tags', async () => { test('successfully validates metadata containing ws federation tags', async () => {
@ -31,8 +35,7 @@ describe('saml-validator', () => {
DQnnT/5se4dqYN86R35MCdbyKVl64lGPLSIVrxFxrOQ9YRK1br7Z1Bt1/LQD4f92z+GwAl+9tZTWhuoy6OGHCV6LlqBEztW43KnlCKw6eaNg4/6NluzJ/XeknXYLURDnfFVyGbLQAYWGND4Qm8CUXO/GjGfWTZuArvrDDC36/2FA41jKXtf1InxGFx1Bbaskx3n3KCFFth/V9knbnc1zftEe022aQluPRoGccROOI4ZeLUFL6+1gYlxjx0gFIOTRiuvrzR765lHNrF7iZ4aD+XukqtkGEtxTkiLoB+Bnr8Fd7IF5rV5FKTZWSxo+ZFcLimrDGtFPItVrC/oKRc+MGA==</SignatureValue> DQnnT/5se4dqYN86R35MCdbyKVl64lGPLSIVrxFxrOQ9YRK1br7Z1Bt1/LQD4f92z+GwAl+9tZTWhuoy6OGHCV6LlqBEztW43KnlCKw6eaNg4/6NluzJ/XeknXYLURDnfFVyGbLQAYWGND4Qm8CUXO/GjGfWTZuArvrDDC36/2FA41jKXtf1InxGFx1Bbaskx3n3KCFFth/V9knbnc1zftEe022aQluPRoGccROOI4ZeLUFL6+1gYlxjx0gFIOTRiuvrzR765lHNrF7iZ4aD+XukqtkGEtxTkiLoB+Bnr8Fd7IF5rV5FKTZWSxo+ZFcLimrDGtFPItVrC/oKRc+MGA==</SignatureValue>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data> <ds:X509Data>
<ds:X509Certificate> <ds:X509Certificate>${VALID_CERTIFICATE}</ds:X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</ds:X509Certificate>
</ds:X509Data> </ds:X509Data>
</ds:KeyInfo> </ds:KeyInfo>
</Signature> </Signature>
@ -43,8 +46,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing"> <KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"> <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data> <X509Data>
<X509Certificate> <X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
</X509Data> </X509Data>
</KeyInfo> </KeyInfo>
</KeyDescriptor> </KeyDescriptor>
@ -169,8 +171,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing"> <KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"> <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data> <X509Data>
<X509Certificate> <X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
</X509Data> </X509Data>
</KeyInfo> </KeyInfo>
</KeyDescriptor> </KeyDescriptor>
@ -194,8 +195,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing"> <KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"> <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data> <X509Data>
<X509Certificate> <X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
</X509Data> </X509Data>
</KeyInfo> </KeyInfo>
</KeyDescriptor> </KeyDescriptor>
@ -209,7 +209,7 @@ describe('saml-validator', () => {
</EntityDescriptor>`; </EntityDescriptor>`;
// ACT // ACT
const result = await validateMetadata(metadata); const result = await validator.validateMetadata(metadata);
// ASSERT // ASSERT
expect(result).toBe(true); expect(result).toBe(true);
@ -225,7 +225,85 @@ describe('saml-validator', () => {
</EntityDescriptor>`; </EntityDescriptor>`;
// ACT // ACT
const result = await validateMetadata(metadata); const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects malformed XML metadata', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>${VALID_CERTIFICATE}
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
`; // Missing closing tags
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects metadata missing SingleSignOnService', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>${VALID_CERTIFICATE}
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
</EntityDescriptor>`;
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects metadata with invalid X.509 certificate', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
INVALID_CERTIFICATE
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://login.microsoftonline.com/random-issuer/saml2" />
</IDPSSODescriptor>
</EntityDescriptor>`;
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT // ASSERT
expect(result).toBe(false); expect(result).toBe(false);
@ -328,13 +406,13 @@ describe('saml-validator', () => {
</samlp:Response>`; </samlp:Response>`;
// ACT // ACT
const result = await validateResponse(response); const result = await validator.validateResponse(response);
// ASSERT // ASSERT
expect(result).toBe(true); expect(result).toBe(true);
}); });
test('rejects invalidate response', async () => { test('rejects invalid response', async () => {
// ARRANGE // ARRANGE
// Invalid because required children are missing // Invalid because required children are missing
const response = `<samlp:Response ID="random_id" Version="2.0" const response = `<samlp:Response ID="random_id" Version="2.0"
@ -344,7 +422,45 @@ describe('saml-validator', () => {
</samlp:Response>`; </samlp:Response>`;
// ACT // ACT
const result = await validateResponse(response); const result = await validator.validateResponse(response);
// ASSERT
expect(result).toBe(false);
});
test('rejects expired SAML response', async () => {
// ARRANGE
const response = `<samlp:Response ID="random_id" Version="2.0"
IssueInstant="2024-11-13T14:58:00.371Z" Destination="random-url"
InResponseTo="random_id"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
https://sts.windows.net/random-issuer/</Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<Assertion ID="_random_id" IssueInstant="2024-11-13T14:58:00.367Z"
Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<Issuer>https://sts.windows.net/random-issuer/</Issuer>
<Subject>
<NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
random_name_id</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData InResponseTo="random_id"
NotOnOrAfter="2023-11-13T15:58:00.284Z" // Expired
Recipient="random-url" />
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="2024-11-13T14:53:00.284Z" NotOnOrAfter="2023-11-13T15:58:00.284Z"> // Expired
<AudienceRestriction>
<Audience>http://localhost:5678/rest/sso/saml/metadata</Audience>
</AudienceRestriction>
</Conditions>
</Assertion>
</samlp:Response>`;
// ACT
const result = await validator.validateResponse(response);
// ASSERT // ASSERT
expect(result).toBe(false); expect(result).toBe(false);

View file

@ -1,10 +1,8 @@
import type express from 'express'; import type express from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { Logger } from 'n8n-core';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { UrlService } from '@/services/url.service';
import * as samlHelpers from '@/sso.ee/saml/saml-helpers'; import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
import { SamlService } from '@/sso.ee/saml/saml.service.ee'; import { SamlService } from '@/sso.ee/saml/saml.service.ee';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
@ -13,10 +11,8 @@ import { SAML_PREFERENCES_DB_KEY } from '../constants';
import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error'; import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error';
describe('SamlService', () => { describe('SamlService', () => {
const logger = mockInstance(Logger);
const urlService = mockInstance(UrlService);
const samlService = new SamlService(logger, urlService);
const settingsRepository = mockInstance(SettingsRepository); const settingsRepository = mockInstance(SettingsRepository);
const samlService = new SamlService(mock(), mock(), mock(), mock(), settingsRepository);
beforeEach(() => { beforeEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();

View file

@ -2,18 +2,14 @@ import { type Response } from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { UrlService } from '@/services/url.service'; import type { AuthlessRequest } from '@/requests';
import { mockInstance } from '@test/mocking';
import { SamlService } from '../../saml.service.ee'; import type { SamlService } from '../../saml.service.ee';
import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee'; import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee';
import type { SamlConfiguration } from '../../types/requests'; import type { SamlUserAttributes } from '../../types';
import type { SamlUserAttributes } from '../../types/saml-user-attributes';
import { SamlController } from '../saml.controller.ee'; import { SamlController } from '../saml.controller.ee';
const urlService = mockInstance(UrlService); const samlService = mock<SamlService>();
urlService.getInstanceBaseUrl.mockReturnValue('');
const samlService = mockInstance(SamlService);
const controller = new SamlController(mock(), samlService, mock(), mock()); const controller = new SamlController(mock(), samlService, mock(), mock());
const user = mock<User>({ const user = mock<User>({
@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = {
}; };
describe('Test views', () => { describe('Test views', () => {
const RelayState = getServiceProviderConfigTestReturnUrl();
test('Should render success with template', async () => { test('Should render success with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>(); const req = mock<AuthlessRequest>();
const res = mock<Response>(); const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockResolvedValueOnce({ samlService.handleSamlLogin.mockResolvedValueOnce({
authenticatedUser: user, authenticatedUser: user,
attributes, attributes,
onboardingRequired: false, onboardingRequired: false,
}); });
await controller.acsPost(req, res); await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-success', attributes); expect(res.render).toBeCalledWith('saml-connection-test-success', attributes);
}); });
test('Should render failure with template', async () => { test('Should render failure with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>(); const req = mock<AuthlessRequest>();
const res = mock<Response>(); const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockResolvedValueOnce({ samlService.handleSamlLogin.mockResolvedValueOnce({
authenticatedUser: undefined, authenticatedUser: undefined,
attributes, attributes,
onboardingRequired: false, onboardingRequired: false,
}); });
await controller.acsPost(req, res); await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes }); expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes });
}); });
test('Should render error with template', async () => { test('Should render error with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>(); const req = mock<AuthlessRequest>();
const res = mock<Response>(); const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockRejectedValueOnce(new Error('Test Error')); samlService.handleSamlLogin.mockRejectedValueOnce(new Error('Test Error'));
await controller.acsPost(req, res); await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: 'Test Error' }); expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: 'Test Error' });
}); });

View file

@ -1,15 +1,14 @@
import { validate } from 'class-validator'; import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import express from 'express'; import { Response } from 'express';
import querystring from 'querystring'; import querystring from 'querystring';
import type { PostBindingContext } from 'samlify/types/src/entity'; import type { PostBindingContext } from 'samlify/types/src/entity';
import url from 'url'; import url from 'url';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { Get, Post, RestController, GlobalScope, Body } from '@/decorators';
import { AuthError } from '@/errors/response-errors/auth.error'; import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import { sendErrorResponse } from '@/response-helper'; import { sendErrorResponse } from '@/response-helper';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
@ -25,7 +24,6 @@ import {
getServiceProviderReturnUrl, getServiceProviderReturnUrl,
} from '../service-provider.ee'; } from '../service-provider.ee';
import type { SamlLoginBinding } from '../types'; import type { SamlLoginBinding } from '../types';
import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/init-sso-post'; import { getInitSSOFormView } from '../views/init-sso-post';
@RestController('/sso/saml') @RestController('/sso/saml')
@ -38,7 +36,7 @@ export class SamlController {
) {} ) {}
@Get('/metadata', { skipAuth: true }) @Get('/metadata', { skipAuth: true })
async getServiceProviderMetadata(_: express.Request, res: express.Response) { async getServiceProviderMetadata(_: AuthlessRequest, res: Response) {
return res return res
.header('Content-Type', 'text/xml') .header('Content-Type', 'text/xml')
.send(this.samlService.getServiceProviderInstance().getMetadata()); .send(this.samlService.getServiceProviderInstance().getMetadata());
@ -62,17 +60,8 @@ export class SamlController {
*/ */
@Post('/config', { middlewares: [samlLicensedMiddleware] }) @Post('/config', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage') @GlobalScope('saml:manage')
async configPost(req: SamlConfiguration.Update) { async configPost(_req: AuthenticatedRequest, _res: Response, @Body payload: SamlPreferences) {
const validationResult = await validate(req.body); return await this.samlService.setSamlPreferences(payload);
if (validationResult.length === 0) {
const result = await this.samlService.setSamlPreferences(req.body);
return result;
} else {
throw new BadRequestError(
'Body is not a valid SamlPreferences object: ' +
validationResult.map((e) => e.toString()).join(','),
);
}
} }
/** /**
@ -80,11 +69,12 @@ export class SamlController {
*/ */
@Post('/config/toggle', { middlewares: [samlLicensedMiddleware] }) @Post('/config/toggle', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage') @GlobalScope('saml:manage')
async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { async toggleEnabledPost(
if (req.body.loginEnabled === undefined) { _req: AuthenticatedRequest,
throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); res: Response,
} @Body { loginEnabled }: SamlToggleDto,
await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled }); ) {
await this.samlService.setSamlPreferences({ loginEnabled });
return res.sendStatus(200); return res.sendStatus(200);
} }
@ -92,7 +82,7 @@ export class SamlController {
* Assertion Consumer Service endpoint * Assertion Consumer Service endpoint
*/ */
@Get('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true }) @Get('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) { async acsGet(req: AuthlessRequest, res: Response) {
return await this.acsHandler(req, res, 'redirect'); return await this.acsHandler(req, res, 'redirect');
} }
@ -100,8 +90,8 @@ export class SamlController {
* Assertion Consumer Service endpoint * Assertion Consumer Service endpoint
*/ */
@Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true }) @Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { async acsPost(req: AuthlessRequest, res: Response, @Body payload: SamlAcsDto) {
return await this.acsHandler(req, res, 'post'); return await this.acsHandler(req, res, 'post', payload);
} }
/** /**
@ -110,14 +100,15 @@ export class SamlController {
* For test connections, returns status 202 if SAML is not enabled * For test connections, returns status 202 if SAML is not enabled
*/ */
private async acsHandler( private async acsHandler(
req: SamlConfiguration.AcsRequest, req: AuthlessRequest,
res: express.Response, res: Response,
binding: SamlLoginBinding, binding: SamlLoginBinding,
payload: SamlAcsDto = {},
) { ) {
try { try {
const loginResult = await this.samlService.handleSamlLogin(req, binding); const loginResult = await this.samlService.handleSamlLogin(req, binding);
// if RelayState is set to the test connection Url, this is a test connection // if RelayState is set to the test connection Url, this is a test connection
if (isConnectionTestRequest(req)) { if (isConnectionTestRequest(payload)) {
if (loginResult.authenticatedUser) { if (loginResult.authenticatedUser) {
return res.render('saml-connection-test-success', loginResult.attributes); return res.render('saml-connection-test-success', loginResult.attributes);
} else { } else {
@ -139,7 +130,7 @@ export class SamlController {
if (loginResult.onboardingRequired) { if (loginResult.onboardingRequired) {
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding'); return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
} else { } else {
const redirectUrl = req.body?.RelayState ?? '/'; const redirectUrl = payload.RelayState ?? '/';
return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl); return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl);
} }
} else { } else {
@ -153,7 +144,7 @@ export class SamlController {
// Need to manually send the error response since we're using templates // Need to manually send the error response since we're using templates
return sendErrorResponse(res, new AuthError('SAML Authentication failed')); return sendErrorResponse(res, new AuthError('SAML Authentication failed'));
} catch (error) { } catch (error) {
if (isConnectionTestRequest(req)) { if (isConnectionTestRequest(payload)) {
return res.render('saml-connection-test-failed', { message: (error as Error).message }); return res.render('saml-connection-test-failed', { message: (error as Error).message });
} }
this.eventService.emit('user-login-failed', { this.eventService.emit('user-login-failed', {
@ -173,7 +164,7 @@ export class SamlController {
* This endpoint is available if SAML is licensed and enabled * This endpoint is available if SAML is licensed and enabled
*/ */
@Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true }) @Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true })
async initSsoGet(req: express.Request, res: express.Response) { async initSsoGet(req: AuthlessRequest, res: Response) {
let redirectUrl = ''; let redirectUrl = '';
try { try {
const refererUrl = req.headers.referer; const refererUrl = req.headers.referer;
@ -198,11 +189,11 @@ export class SamlController {
*/ */
@Get('/config/test', { middlewares: [samlLicensedMiddleware] }) @Get('/config/test', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage') @GlobalScope('saml:manage')
async configTestGet(_: AuthenticatedRequest, res: express.Response) { async configTestGet(_: AuthenticatedRequest, res: Response) {
return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl());
} }
private async handleInitSSO(res: express.Response, relayState?: string) { private async handleInitSSO(res: Response, relayState?: string) {
const result = await this.samlService.getLoginRequestUrl(relayState); const result = await this.samlService.getLoginRequestUrl(relayState);
if (result?.binding === 'redirect') { if (result?.binding === 'redirect') {
return result.context.context; return result.context.context;

View file

@ -1,3 +1,4 @@
import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import type { FlowResult } from 'samlify/types/src/flow'; import type { FlowResult } from 'samlify/types/src/flow';
import { Container } from 'typedi'; import { Container } from 'typedi';
@ -14,10 +15,7 @@ import { PasswordUtility } from '@/services/password.utility';
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee'; import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee';
import type { SamlConfiguration } from './types/requests'; import type { SamlAttributeMapping, SamlUserAttributes } from './types';
import type { SamlAttributeMapping } from './types/saml-attribute-mapping';
import type { SamlPreferences } from './types/saml-preferences';
import type { SamlUserAttributes } from './types/saml-user-attributes';
import { import {
getCurrentAuthenticationMethod, getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod,
@ -165,6 +163,6 @@ export function getMappedSamlAttributesFromFlowResult(
return result; return result;
} }
export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { export function isConnectionTestRequest(payload: SamlAcsDto): boolean {
return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); return payload.RelayState === getServiceProviderConfigTestReturnUrl();
} }

View file

@ -1,115 +1,87 @@
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import { Container } from 'typedi'; import { Service } from 'typedi';
import type { XMLFileInfo } from 'xmllint-wasm'; import type { XMLFileInfo, XMLLintOptions, XMLValidationResult } from 'xmllint-wasm';
let xmlMetadata: XMLFileInfo; @Service()
let xmlProtocol: XMLFileInfo; export class SamlValidator {
private xmlMetadata: XMLFileInfo;
let preload: XMLFileInfo[] = []; private xmlProtocol: XMLFileInfo;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports private preload: XMLFileInfo[] = [];
let xmllintWasm: typeof import('xmllint-wasm') | undefined;
// dynamically load schema files constructor(private readonly logger: Logger) {}
async function loadSchemas(): Promise<void> {
xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo;
xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo;
preload = (
await Promise.all([
// SAML
import('./schema/saml-schema-assertion-2.0.xsd'),
import('./schema/xmldsig-core-schema.xsd'),
import('./schema/xenc-schema.xsd'),
import('./schema/xml.xsd'),
// WS-Federation private xmllint: {
import('./schema/ws-federation.xsd'), validateXML: (options: XMLLintOptions) => Promise<XMLValidationResult>;
import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'), };
import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'),
import('./schema/ws-addr.xsd'),
import('./schema/metadata-exchange.xsd'),
import('./schema/ws-securitypolicy-1.2.xsd'),
import('./schema/ws-authorization.xsd'),
])
).map((m) => m.xmlFileInfo);
}
// dynamically load xmllint-wasm async init() {
async function loadXmllintWasm(): Promise<void> { await this.loadSchemas();
if (xmllintWasm === undefined) { this.xmllint = await import('xmllint-wasm');
Container.get(Logger).debug('Loading xmllint-wasm library into memory');
xmllintWasm = await import('xmllint-wasm');
} }
}
export async function validateMetadata(metadata: string): Promise<boolean> { async validateMetadata(metadata: string): Promise<boolean> {
const logger = Container.get(Logger); return await this.validateXml('metadata', metadata);
try { }
await loadXmllintWasm();
await loadSchemas(); async validateResponse(response: string): Promise<boolean> {
const validationResult = await xmllintWasm?.validateXML({ return await this.validateXml('response', response);
xml: [ }
{
fileName: 'metadata.xml', // dynamically load schema files
contents: metadata, private async loadSchemas(): Promise<void> {
}, this.xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo;
], this.xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo;
extension: 'schema', this.preload = (
schema: [xmlMetadata], await Promise.all([
preload: [xmlProtocol, ...preload], // SAML
}); import('./schema/saml-schema-assertion-2.0.xsd'),
if (validationResult?.valid) { import('./schema/xmldsig-core-schema.xsd'),
logger.debug('SAML Metadata is valid'); import('./schema/xenc-schema.xsd'),
return true; import('./schema/xml.xsd'),
} else {
logger.warn('SAML Validate Metadata: Invalid metadata'); // WS-Federation
logger.warn( import('./schema/ws-federation.xsd'),
validationResult import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'),
? validationResult.errors import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'),
.map((error) => `${error.message} - ${error.rawMessage}`) import('./schema/ws-addr.xsd'),
.join('\n') import('./schema/metadata-exchange.xsd'),
: '', import('./schema/ws-securitypolicy-1.2.xsd'),
); import('./schema/ws-authorization.xsd'),
])
).map((m) => m.xmlFileInfo);
}
private async validateXml(type: 'metadata' | 'response', contents: string): Promise<boolean> {
const fileName = `${type}.xml`;
const schema = type === 'metadata' ? [this.xmlMetadata] : [this.xmlProtocol];
const preload = [type === 'metadata' ? this.xmlProtocol : this.xmlMetadata, ...this.preload];
try {
const validationResult = await this.xmllint.validateXML({
xml: [{ fileName, contents }],
extension: 'schema',
schema,
preload,
});
if (validationResult?.valid) {
this.logger.debug(`SAML ${type} is valid`);
return true;
} else {
this.logger.debug(`SAML ${type} is invalid`);
this.logger.warn(
validationResult
? validationResult.errors
.map((error) => `${error.message} - ${error.rawMessage}`)
.join('\n')
: '',
);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.logger.warn(error);
} }
} catch (error) { return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger.warn(error);
} }
return false;
}
export async function validateResponse(response: string): Promise<boolean> {
const logger = Container.get(Logger);
try {
await loadXmllintWasm();
await loadSchemas();
const validationResult = await xmllintWasm?.validateXML({
xml: [
{
fileName: 'response.xml',
contents: response,
},
],
extension: 'schema',
schema: [xmlProtocol],
preload: [xmlMetadata, ...preload],
});
if (validationResult?.valid) {
logger.debug('SAML Response is valid');
return true;
} else {
logger.warn('SAML Validate Response: Failed');
logger.warn(
validationResult
? validationResult.errors
.map((error) => `${error.message} - ${error.rawMessage}`)
.join('\n')
: '',
);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger.warn(error);
}
return false;
} }

View file

@ -1,3 +1,4 @@
import type { SamlPreferences } from '@n8n/api-types';
import axios from 'axios'; import axios from 'axios';
import type express from 'express'; import type express from 'express';
import https from 'https'; import https from 'https';
@ -5,7 +6,7 @@ import { Logger } from 'n8n-core';
import { ApplicationError, jsonParse } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import Container, { Service } from 'typedi'; import { Service } from 'typedi';
import type { Settings } from '@/databases/entities/settings'; import type { Settings } from '@/databases/entities/settings';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
@ -27,11 +28,9 @@ import {
setSamlLoginLabel, setSamlLoginLabel,
updateUserFromSamlAttributes, updateUserFromSamlAttributes,
} from './saml-helpers'; } from './saml-helpers';
import { validateMetadata, validateResponse } from './saml-validator'; import { SamlValidator } from './saml-validator';
import { getServiceProviderInstance } from './service-provider.ee'; import { getServiceProviderInstance } from './service-provider.ee';
import type { SamlLoginBinding } from './types'; import type { SamlLoginBinding, SamlUserAttributes } from './types';
import type { SamlPreferences } from './types/saml-preferences';
import type { SamlUserAttributes } from './types/saml-user-attributes';
import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers'; import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers';
@Service() @Service()
@ -79,12 +78,16 @@ export class SamlService {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly validator: SamlValidator,
private readonly userRepository: UserRepository,
private readonly settingsRepository: SettingsRepository,
) {} ) {}
async init(): Promise<void> { async init(): Promise<void> {
try { try {
// load preferences first but do not apply so as to not load samlify unnecessarily // load preferences first but do not apply so as to not load samlify unnecessarily
await this.loadFromDbAndApplySamlPreferences(false); await this.loadFromDbAndApplySamlPreferences(false);
await this.validator.init();
if (isSamlLicensedAndEnabled()) { if (isSamlLicensedAndEnabled()) {
await this.loadSamlify(); await this.loadSamlify();
await this.loadFromDbAndApplySamlPreferences(true); await this.loadFromDbAndApplySamlPreferences(true);
@ -108,9 +111,10 @@ export class SamlService {
this.logger.debug('Loading samlify library into memory'); this.logger.debug('Loading samlify library into memory');
this.samlify = await import('samlify'); this.samlify = await import('samlify');
} }
this.samlify.setSchemaValidator({ this.samlify.setSchemaValidator({
validate: async (response: string) => { validate: async (response: string) => {
const valid = await validateResponse(response); const valid = await this.validator.validateResponse(response);
if (!valid) { if (!valid) {
throw new InvalidSamlMetadataError(); throw new InvalidSamlMetadataError();
} }
@ -188,7 +192,7 @@ export class SamlService {
const attributes = await this.getAttributesFromLoginResponse(req, binding); const attributes = await this.getAttributesFromLoginResponse(req, binding);
if (attributes.email) { if (attributes.email) {
const lowerCasedEmail = attributes.email.toLowerCase(); const lowerCasedEmail = attributes.email.toLowerCase();
const user = await Container.get(UserRepository).findOne({ const user = await this.userRepository.findOne({
where: { email: lowerCasedEmail }, where: { email: lowerCasedEmail },
relations: ['authIdentities'], relations: ['authIdentities'],
}); });
@ -233,7 +237,7 @@ export class SamlService {
}; };
} }
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> { async setSamlPreferences(prefs: Partial<SamlPreferences>): Promise<SamlPreferences | undefined> {
await this.loadSamlify(); await this.loadSamlify();
await this.loadPreferencesWithoutValidation(prefs); await this.loadPreferencesWithoutValidation(prefs);
if (prefs.metadataUrl) { if (prefs.metadataUrl) {
@ -242,7 +246,7 @@ export class SamlService {
this._samlPreferences.metadata = fetchedMetadata; this._samlPreferences.metadata = fetchedMetadata;
} }
} else if (prefs.metadata) { } else if (prefs.metadata) {
const validationResult = await validateMetadata(prefs.metadata); const validationResult = await this.validator.validateMetadata(prefs.metadata);
if (!validationResult) { if (!validationResult) {
throw new InvalidSamlMetadataError(); throw new InvalidSamlMetadataError();
} }
@ -252,7 +256,7 @@ export class SamlService {
return result; return result;
} }
async loadPreferencesWithoutValidation(prefs: SamlPreferences) { async loadPreferencesWithoutValidation(prefs: Partial<SamlPreferences>) {
this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding; this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
@ -278,7 +282,7 @@ export class SamlService {
} }
async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> { async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
const samlPreferences = await Container.get(SettingsRepository).findOne({ const samlPreferences = await this.settingsRepository.findOne({
where: { key: SAML_PREFERENCES_DB_KEY }, where: { key: SAML_PREFERENCES_DB_KEY },
}); });
if (samlPreferences) { if (samlPreferences) {
@ -296,18 +300,18 @@ export class SamlService {
} }
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> { async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
const samlPreferences = await Container.get(SettingsRepository).findOne({ const samlPreferences = await this.settingsRepository.findOne({
where: { key: SAML_PREFERENCES_DB_KEY }, where: { key: SAML_PREFERENCES_DB_KEY },
}); });
const settingsValue = JSON.stringify(this.samlPreferences); const settingsValue = JSON.stringify(this.samlPreferences);
let result: Settings; let result: Settings;
if (samlPreferences) { if (samlPreferences) {
samlPreferences.value = settingsValue; samlPreferences.value = settingsValue;
result = await Container.get(SettingsRepository).save(samlPreferences, { result = await this.settingsRepository.save(samlPreferences, {
transaction: false, transaction: false,
}); });
} else { } else {
result = await Container.get(SettingsRepository).save( result = await this.settingsRepository.save(
{ {
key: SAML_PREFERENCES_DB_KEY, key: SAML_PREFERENCES_DB_KEY,
value: settingsValue, value: settingsValue,
@ -332,7 +336,7 @@ export class SamlService {
const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent }); const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent });
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
const xml = (await response.data) as string; const xml = (await response.data) as string;
const validationResult = await validateMetadata(xml); const validationResult = await this.validator.validateMetadata(xml);
if (!validationResult) { if (!validationResult) {
throw new BadRequestError( throw new BadRequestError(
`Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`, `Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`,
@ -392,6 +396,6 @@ export class SamlService {
*/ */
async reset() { async reset() {
await setSamlLoginEnabled(false); await setSamlLoginEnabled(false);
await Container.get(SettingsRepository).delete({ key: SAML_PREFERENCES_DB_KEY }); await this.settingsRepository.delete({ key: SAML_PREFERENCES_DB_KEY });
} }
} }

View file

@ -1,10 +1,9 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { ServiceProviderInstance } from 'samlify'; import type { ServiceProviderInstance } from 'samlify';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import type { SamlPreferences } from './types/saml-preferences';
let serviceProviderInstance: ServiceProviderInstance | undefined; let serviceProviderInstance: ServiceProviderInstance | undefined;
export function getServiceProviderEntityId(): string { export function getServiceProviderEntityId(): string {

View file

@ -0,0 +1,5 @@
import type { SamlPreferences } from '@n8n/api-types';
export type SamlLoginBinding = SamlPreferences['loginBinding'];
export type SamlAttributeMapping = NonNullable<SamlPreferences['mapping']>;
export type SamlUserAttributes = SamlAttributeMapping;

View file

@ -1 +0,0 @@
export type SamlLoginBinding = 'post' | 'redirect';

View file

@ -1,17 +0,0 @@
import type { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import type { SamlPreferences } from './saml-preferences';
export declare namespace SamlConfiguration {
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>;
type AcsRequest = AuthlessRequest<
{},
{},
{
RelayState?: string;
},
{}
>;
}

View file

@ -1,6 +0,0 @@
export interface SamlAttributeMapping {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -1,65 +0,0 @@
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
import { SignatureConfig } from 'samlify/types/src/types';
import { SamlLoginBinding } from '.';
import { SamlAttributeMapping } from './saml-attribute-mapping';
export class SamlPreferences {
@IsObject()
@IsOptional()
mapping?: SamlAttributeMapping;
@IsString()
@IsOptional()
metadata?: string;
@IsString()
@IsOptional()
metadataUrl?: string;
@IsBoolean()
@IsOptional()
ignoreSSL?: boolean = false;
@IsString()
@IsOptional()
loginBinding?: SamlLoginBinding = 'redirect';
@IsBoolean()
@IsOptional()
loginEnabled?: boolean;
@IsString()
@IsOptional()
loginLabel?: string;
@IsBoolean()
@IsOptional()
authnRequestsSigned?: boolean = false;
@IsBoolean()
@IsOptional()
wantAssertionsSigned?: boolean = true;
@IsBoolean()
@IsOptional()
wantMessageSigned?: boolean = true;
@IsString()
@IsOptional()
acsBinding?: SamlLoginBinding = 'post';
@IsObject()
@IsOptional()
signatureConfig?: SignatureConfig = {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
};
@IsString()
@IsOptional()
relayState?: string = '';
}

View file

@ -1,6 +0,0 @@
export interface SamlUserAttributes {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -154,11 +154,7 @@ export class TestWebhooks implements IWebhookManager {
* the webhook. If so, after the test webhook has been successfully executed, * the webhook. If so, after the test webhook has been successfully executed,
* the handler process commands the creator process to clear its test webhooks. * the handler process commands the creator process to clear its test webhooks.
*/ */
if ( if (this.instanceSettings.isMultiMain && pushRef && !this.push.hasPushRef(pushRef)) {
this.instanceSettings.isMultiMain &&
pushRef &&
!this.push.getBackend().hasPushRef(pushRef)
) {
void this.publisher.publishCommand({ void this.publisher.publishCommand({
command: 'clear-test-webhooks', command: 'clear-test-webhooks',
payload: { webhookKey: key, workflowEntity, pushRef }, payload: { webhookKey: key, workflowEntity, pushRef },

View file

@ -37,10 +37,12 @@ import {
FORM_NODE_TYPE, FORM_NODE_TYPE,
NodeOperationError, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import assert from 'node:assert';
import { finished } from 'stream/promises'; import { finished } from 'stream/promises';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import config from '@/config';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -531,6 +533,15 @@ export async function executeWebhook(
}); });
} }
if (
config.getEnv('executions.mode') === 'queue' &&
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true' &&
runData.executionMode === 'manual'
) {
assert(runData.executionData);
runData.executionData.isTestWebhook = true;
}
// Start now to run the workflow // Start now to run the workflow
executionId = await Container.get(WorkflowRunner).run( executionId = await Container.get(WorkflowRunner).run(
runData, runData,

View file

@ -5,7 +5,13 @@
import type { PushMessage, PushType } from '@n8n/api-types'; import type { PushMessage, PushType } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { stringify } from 'flatted'; import { stringify } from 'flatted';
import { ErrorReporter, Logger, WorkflowExecute, isObjectLiteral } from 'n8n-core'; import {
ErrorReporter,
Logger,
InstanceSettings,
WorkflowExecute,
isObjectLiteral,
} from 'n8n-core';
import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
@ -1076,8 +1082,7 @@ function getWorkflowHooksIntegrated(
} }
/** /**
* Returns WorkflowHooks instance for running integrated workflows * Returns WorkflowHooks instance for worker in scaling mode.
* (Workflows which get started inside of another workflow)
*/ */
export function getWorkflowHooksWorkerExecuter( export function getWorkflowHooksWorkerExecuter(
mode: WorkflowExecuteMode, mode: WorkflowExecuteMode,
@ -1093,6 +1098,17 @@ export function getWorkflowHooksWorkerExecuter(
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
} }
if (mode === 'manual' && Container.get(InstanceSettings).isWorker) {
const pushHooks = hookFunctionsPush();
for (const key of Object.keys(pushHooks)) {
if (hookFunctions[key] === undefined) {
hookFunctions[key] = [];
}
// eslint-disable-next-line prefer-spread
hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]);
}
}
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
} }

View file

@ -82,7 +82,7 @@ export class WorkflowRunner {
// in queue mode, first do a sanity run for the edge case that the execution was not marked as stalled // in queue mode, first do a sanity run for the edge case that the execution was not marked as stalled
// by Bull even though it executed successfully, see https://github.com/OptimalBits/bull/issues/1415 // by Bull even though it executed successfully, see https://github.com/OptimalBits/bull/issues/1415
if (isQueueMode && executionMode !== 'manual') { if (isQueueMode) {
const executionWithoutData = await this.executionRepository.findSingleExecution(executionId, { const executionWithoutData = await this.executionRepository.findSingleExecution(executionId, {
includeData: false, includeData: false,
}); });
@ -153,9 +153,13 @@ export class WorkflowRunner {
this.activeExecutions.attachResponsePromise(executionId, responsePromise); this.activeExecutions.attachResponsePromise(executionId, responsePromise);
} }
if (this.executionsMode === 'queue' && data.executionMode !== 'manual') { // @TODO: Reduce to true branch once feature is stable
// Do not run "manual" executions in bull because sending events to the const shouldEnqueue =
// frontend would not be possible process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true'
? this.executionsMode === 'queue'
: this.executionsMode === 'queue' && data.executionMode !== 'manual';
if (shouldEnqueue) {
await this.enqueueExecution(executionId, data, loadStaticData, realtime); await this.enqueueExecution(executionId, data, loadStaticData, realtime);
} else { } else {
await this.runMainProcess(executionId, data, loadStaticData, restartExecutionId); await this.runMainProcess(executionId, data, loadStaticData, restartExecutionId);
@ -349,6 +353,7 @@ export class WorkflowRunner {
const jobData: JobData = { const jobData: JobData = {
executionId, executionId,
loadStaticData: !!loadStaticData, loadStaticData: !!loadStaticData,
pushRef: data.pushRef,
}; };
if (!this.scalingService) { if (!this.scalingService) {

View file

@ -0,0 +1,72 @@
import type { ImportWorkflowFromUrlDto } from '@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto';
import axios from 'axios';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { AuthenticatedRequest } from '@/requests';
import { WorkflowsController } from '../workflows.controller';
jest.mock('axios');
describe('WorkflowsController', () => {
const controller = Object.create(WorkflowsController.prototype);
const axiosMock = axios.get as jest.Mock;
const req = mock<AuthenticatedRequest>();
const res = mock<Response>();
describe('getFromUrl', () => {
describe('should return workflow data', () => {
it('when the URL points to a valid JSON file', async () => {
const mockWorkflowData = {
nodes: [],
connections: {},
};
axiosMock.mockResolvedValue({ data: mockWorkflowData });
const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/workflow.json' };
const result = await controller.getFromUrl(req, res, query);
expect(result).toEqual(mockWorkflowData);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
});
describe('should throw a BadRequestError', () => {
const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/invalid.json' };
it('when the URL does not point to a valid JSON file', async () => {
axiosMock.mockRejectedValue(new Error('Network Error'));
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
it('when the data is not a valid n8n workflow JSON', async () => {
const invalidWorkflowData = {
nodes: 'not an array',
connections: 'not an object',
};
axiosMock.mockResolvedValue({ data: invalidWorkflowData });
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
it('when the data is missing required fields', async () => {
const incompleteWorkflowData = {
nodes: [],
// Missing connections field
};
axiosMock.mockResolvedValue({ data: incompleteWorkflowData });
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
});
});
});

View file

@ -15,6 +15,7 @@ import type {
import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow } from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import config from '@/config';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
@ -146,6 +147,35 @@ export class WorkflowExecutionService {
triggerToStartFrom, triggerToStartFrom,
}; };
/**
* Historically, manual executions in scaling mode ran in the main process,
* so some execution details were never persisted in the database.
*
* Currently, manual executions in scaling mode are offloaded to workers,
* so we persist all details to give workers full access to them.
*/
if (
config.getEnv('executions.mode') === 'queue' &&
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true'
) {
data.executionData = {
startData: {
startNodes,
destinationNode,
},
resultData: {
pinData,
runData,
},
manualData: {
userId: data.userId,
partialExecutionVersion: data.partialExecutionVersion,
dirtyNodeNames,
triggerToStartFrom,
},
};
}
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
if (pinnedTrigger && !hasRunData(pinnedTrigger)) { if (pinnedTrigger && !hasRunData(pinnedTrigger)) {

View file

@ -69,6 +69,4 @@ export declare namespace WorkflowRequest {
{}, {},
{ destinationProjectId: string } { destinationProjectId: string }
>; >;
type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>;
} }

View file

@ -1,3 +1,4 @@
import { ImportWorkflowFromUrlDto } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm'; import { In, type FindOptionsRelations } from '@n8n/typeorm';
@ -18,7 +19,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import * as Db from '@/db'; import * as Db from '@/db';
import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators'; import { Delete, Get, Patch, Post, ProjectScope, Put, Query, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@ -29,6 +30,7 @@ import { validateEntity } from '@/generic-helpers';
import type { IWorkflowResponse } from '@/interfaces'; import type { IWorkflowResponse } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { listQueryMiddleware } from '@/middlewares'; import { listQueryMiddleware } from '@/middlewares';
import { AuthenticatedRequest } from '@/requests';
import * as ResponseHelper from '@/response-helper'; import * as ResponseHelper from '@/response-helper';
import { NamingService } from '@/services/naming.service'; import { NamingService } from '@/services/naming.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
@ -215,18 +217,14 @@ export class WorkflowsController {
} }
@Get('/from-url') @Get('/from-url')
async getFromUrl(req: WorkflowRequest.FromUrl) { async getFromUrl(
if (req.query.url === undefined) { _req: AuthenticatedRequest,
throw new BadRequestError('The parameter "url" is missing!'); _res: express.Response,
} @Query query: ImportWorkflowFromUrlDto,
if (!/^http[s]?:\/\/.*\.json$/i.exec(req.query.url)) { ) {
throw new BadRequestError(
'The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.',
);
}
let workflowData: IWorkflowResponse | undefined; let workflowData: IWorkflowResponse | undefined;
try { try {
const { data } = await axios.get<IWorkflowResponse>(req.query.url); const { data } = await axios.get<IWorkflowResponse>(query.url);
workflowData = data; workflowData = data;
} catch (error) { } catch (error) {
throw new BadRequestError('The URL does not point to valid JSON file!'); throw new BadRequestError('The URL does not point to valid JSON file!');

View file

@ -16,7 +16,7 @@ import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/wor
import * as testDb from '@test-integration/test-db'; import * as testDb from '@test-integration/test-db';
describe('CollaborationService', () => { describe('CollaborationService', () => {
mockInstance(Push, new Push(mock(), mock())); mockInstance(Push, new Push(mock(), mock(), mock()));
let pushService: Push; let pushService: Push;
let collaborationService: CollaborationService; let collaborationService: CollaborationService;
let owner: User; let owner: User;

View file

@ -1,5 +1,5 @@
import * as helpers from '@/sso.ee/saml/saml-helpers'; import * as helpers from '@/sso.ee/saml/saml-helpers';
import type { SamlUserAttributes } from '@/sso.ee/saml/types/saml-user-attributes'; import type { SamlUserAttributes } from '@/sso.ee/saml/types';
import { getPersonalProject } from '../shared/db/projects'; import { getPersonalProject } from '../shared/db/projects';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';

View file

@ -34,6 +34,8 @@ beforeAll(async () => {
authMemberAgent = testServer.authAgentFor(someUser); authMemberAgent = testServer.authAgentFor(someUser);
}); });
beforeEach(async () => await enableSaml(false));
describe('Instance owner', () => { describe('Instance owner', () => {
describe('PATCH /me', () => { describe('PATCH /me', () => {
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
@ -89,6 +91,17 @@ describe('Instance owner', () => {
.expect(200); .expect(200);
expect(getCurrentAuthenticationMethod()).toBe('saml'); expect(getCurrentAuthenticationMethod()).toBe('saml');
}); });
test('should return 400 on invalid config', async () => {
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
loginBinding: 'invalid',
})
.expect(400);
expect(getCurrentAuthenticationMethod()).toBe('email');
});
}); });
describe('POST /sso/saml/config/toggle', () => { describe('POST /sso/saml/config/toggle', () => {

File diff suppressed because one or more lines are too long

View file

@ -209,8 +209,10 @@ export const setupTestServer = ({
break; break;
case 'saml': case 'saml':
const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers'); const { SamlService } = await import('@/sso.ee/saml/saml.service.ee');
await Container.get(SamlService).init();
await import('@/sso.ee/saml/routes/saml.controller.ee'); await import('@/sso.ee/saml/routes/saml.controller.ee');
const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers');
await setSamlLoginEnabled(true); await setSamlLoginEnabled(true);
break; break;

View file

@ -113,6 +113,10 @@ export class InstanceSettings {
return !this.isMultiMain; return !this.isMultiMain;
} }
get isWorker() {
return this.instanceType === 'worker';
}
get isLeader() { get isLeader() {
return this.instanceRole === 'leader'; return this.instanceRole === 'leader';
} }

View file

@ -196,7 +196,7 @@
@mixin n8n-button-danger { @mixin n8n-button-danger {
--button-font-color: var(--color-button-danger-font); --button-font-color: var(--color-button-danger-font);
--button-border-color: var(--color-danger); --button-border-color: var(--color-button-danger-border);
--button-background-color: var(--color-danger); --button-background-color: var(--color-danger);
--button-hover-font-color: var(--color-button-danger-font); --button-hover-font-color: var(--color-button-danger-font);
@ -210,11 +210,11 @@
--button-focus-font-color: var(--color-button-danger-font); --button-focus-font-color: var(--color-button-danger-font);
--button-focus-border-color: var(--color-danger); --button-focus-border-color: var(--color-danger);
--button-focus-background-color: var(--color-danger); --button-focus-background-color: var(--color-danger);
--button-focus-outline-color: var(--color-danger-tint-1); --button-focus-outline-color: var(--color-button-danger-focus-outline);
--button-disabled-font-color: var(--color-button-danger-disabled-font); --button-disabled-font-color: var(--color-button-danger-disabled-font);
--button-disabled-border-color: var(--color-danger-tint-1); --button-disabled-border-color: var(--color-button-danger-disabled-border);
--button-disabled-background-color: var(--color-danger-tint-1); --button-disabled-background-color: var(--color-button-danger-disabled-background);
--button-loading-font-color: var(--color-button-danger-font); --button-loading-font-color: var(--color-button-danger-font);
--button-loading-border-color: var(--color-danger); --button-loading-border-color: var(--color-danger);

View file

@ -36,6 +36,7 @@ export { default as N8nOption } from './N8nOption';
export { default as N8nPopover } from './N8nPopover'; export { default as N8nPopover } from './N8nPopover';
export { default as N8nPulse } from './N8nPulse'; export { default as N8nPulse } from './N8nPulse';
export { default as N8nRadioButtons } from './N8nRadioButtons'; export { default as N8nRadioButtons } from './N8nRadioButtons';
export { default as N8nRoute } from './N8nRoute';
export { default as N8nRecycleScroller } from './N8nRecycleScroller'; export { default as N8nRecycleScroller } from './N8nRecycleScroller';
export { default as N8nResizeWrapper } from './N8nResizeWrapper'; export { default as N8nResizeWrapper } from './N8nResizeWrapper';
export { default as N8nSelect } from './N8nSelect'; export { default as N8nSelect } from './N8nSelect';

View file

@ -188,6 +188,14 @@
--color-button-secondary-disabled-font: var(--prim-gray-0-alpha-030); --color-button-secondary-disabled-font: var(--prim-gray-0-alpha-030);
--color-button-secondary-disabled-border: var(--prim-gray-0-alpha-030); --color-button-secondary-disabled-border: var(--prim-gray-0-alpha-030);
// Button success, warning, danger
--color-button-danger-font: var(--prim-gray-0);
--color-button-danger-border: transparent;
--color-button-danger-focus-outline: var(--prim-color-alt-c-tint-250);
--color-button-danger-disabled-font: var(--prim-gray-0-alpha-025);
--color-button-danger-disabled-border: transparent;
--color-button-danger-disabled-background: var(--prim-color-alt-c-alpha-02);
// Text button // Text button
--color-text-button-secondary-font: var(--prim-gray-320); --color-text-button-secondary-font: var(--prim-gray-320);

View file

@ -244,7 +244,11 @@
--color-button-warning-font: var(--color-text-xlight); --color-button-warning-font: var(--color-text-xlight);
--color-button-warning-disabled-font: var(--prim-gray-0-alpha-075); --color-button-warning-disabled-font: var(--prim-gray-0-alpha-075);
--color-button-danger-font: var(--color-text-xlight); --color-button-danger-font: var(--color-text-xlight);
--color-button-danger-border: var(--color-danger);
--color-button-danger-focus-outline: var(--color-danger-tint-1);
--color-button-danger-disabled-font: var(--prim-gray-0-alpha-075); --color-button-danger-disabled-font: var(--prim-gray-0-alpha-075);
--color-button-danger-disabled-border: var(--color-danger-tint-1);
--color-button-danger-disabled-background: var(--color-danger-tint-1);
// Text button // Text button
--color-text-button-secondary-font: var(--prim-gray-670); --color-text-button-secondary-font: var(--prim-gray-670);

View file

@ -56,7 +56,6 @@ import type {
ROLE, ROLE,
} from '@/constants'; } from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history'; import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy } from '@/utils/typeHelpers';
import type { ProjectSharingData } from '@/types/projects.types'; import type { ProjectSharingData } from '@/types/projects.types';
@ -1300,41 +1299,6 @@ export type ExecutionsQueryFilter = {
vote?: ExecutionFilterVote; vote?: ExecutionFilterVote;
}; };
export type SamlAttributeMapping = {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
};
export type SamlLoginBinding = 'post' | 'redirect';
export type SamlSignatureConfig = {
prefix: 'ds';
location: {
reference: '/samlp:Response/saml:Issuer';
action: 'after';
};
};
export type SamlPreferencesLoginEnabled = {
loginEnabled: boolean;
};
export type SamlPreferences = {
mapping?: SamlAttributeMapping;
metadata?: string;
metadataUrl?: string;
ignoreSSL?: boolean;
loginBinding?: SamlLoginBinding;
acsBinding?: SamlLoginBinding;
authnRequestsSigned?: boolean;
loginLabel?: string;
wantAssertionsSigned?: boolean;
wantMessageSigned?: boolean;
signatureConfig?: SamlSignatureConfig;
} & PartialBy<SamlPreferencesLoginEnabled, 'loginEnabled'>;
export type SamlPreferencesExtractedData = { export type SamlPreferencesExtractedData = {
entityID: string; entityID: string;
returnUrl: string; returnUrl: string;

View file

@ -1,16 +1,17 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { Server, Request } from 'miragejs'; import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs'; import { Response } from 'miragejs';
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface'; import type { SamlPreferencesExtractedData } from '@/Interface';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { AppSchema } from '@/__tests__/server/types'; import type { AppSchema } from '@/__tests__/server/types';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
let samlConfig: SamlPreferences & SamlPreferencesExtractedData = { let samlConfig = {
metadata: '<?xml version="1.0"?>', metadata: '<?xml version="1.0"?>',
metadataUrl: '', metadataUrl: '',
entityID: faker.internet.url(), entityID: faker.internet.url(),
returnUrl: faker.internet.url(), returnUrl: faker.internet.url(),
}; } as SamlPreferences & SamlPreferencesExtractedData;
export function routesForSSO(server: Server) { export function routesForSSO(server: Server) {
server.get('/rest/sso/saml/config', () => { server.get('/rest/sso/saml/config', () => {

View file

@ -1,10 +1,6 @@
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { import type { IRestApiContext, SamlPreferencesExtractedData } from '@/Interface';
IRestApiContext,
SamlPreferencesLoginEnabled,
SamlPreferences,
SamlPreferencesExtractedData,
} from '@/Interface';
export const initSSO = async (context: IRestApiContext): Promise<string> => { export const initSSO = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso'); return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
@ -22,14 +18,14 @@ export const getSamlConfig = async (
export const saveSamlConfig = async ( export const saveSamlConfig = async (
context: IRestApiContext, context: IRestApiContext,
data: SamlPreferences, data: Partial<SamlPreferences>,
): Promise<SamlPreferences | undefined> => { ): Promise<SamlPreferences | undefined> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data); return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
}; };
export const toggleSamlConfig = async ( export const toggleSamlConfig = async (
context: IRestApiContext, context: IRestApiContext,
data: SamlPreferencesLoginEnabled, data: SamlToggleDto,
): Promise<void> => { ): Promise<void> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data); return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data);
}; };

View file

@ -1,18 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@/composables/useStorage'; import { useStorage } from '@/composables/useStorage';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import type { import {
IBinaryData, type IBinaryData,
IConnectedNode, type IConnectedNode,
IDataObject, type IDataObject,
INodeExecutionData, type INodeExecutionData,
INodeOutputConfiguration, type INodeOutputConfiguration,
IRunData, type IRunData,
IRunExecutionData, type IRunExecutionData,
ITaskMetadata, type ITaskMetadata,
NodeError, type NodeError,
NodeHint, type NodeHint,
Workflow, type Workflow,
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'; import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
@ -64,6 +65,7 @@ import { isEqual, isObject } from 'lodash-es';
import { import {
N8nBlockUi, N8nBlockUi,
N8nButton, N8nButton,
N8nRoute,
N8nCallout, N8nCallout,
N8nIconButton, N8nIconButton,
N8nInfoTip, N8nInfoTip,
@ -275,6 +277,10 @@ const isArtificialRecoveredEventItem = computed(
() => rawInputData.value?.[0]?.json?.isArtificialRecoveredEventItem, () => rawInputData.value?.[0]?.json?.isArtificialRecoveredEventItem,
); );
const isTrimmedManualExecutionDataItem = computed(
() => rawInputData.value?.[0]?.json?.[TRIMMED_TASK_DATA_CONNECTIONS_KEY],
);
const subworkflowExecutionError = computed(() => { const subworkflowExecutionError = computed(() => {
if (!node.value) return null; if (!node.value) return null;
return { return {
@ -1245,6 +1251,10 @@ function onSearchClear() {
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' })); document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
} }
function onExecutionHistoryNavigate() {
ndvStore.setActiveNodeName(null);
}
function getExecutionLinkLabel(task: ITaskMetadata): string | undefined { function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
if (task.parentExecution) { if (task.parentExecution) {
return i18n.baseText('runData.openParentExecution', { return i18n.baseText('runData.openParentExecution', {
@ -1310,7 +1320,7 @@ defineExpose({ enterEditMode });
<slot name="header"></slot> <slot name="header"></slot>
<div <div
v-show="!hasRunError" v-show="!hasRunError && !isTrimmedManualExecutionDataItem"
:class="$style.displayModes" :class="$style.displayModes"
data-test-id="run-data-pane-header" data-test-id="run-data-pane-header"
@click.stop @click.stop
@ -1591,6 +1601,20 @@ defineExpose({ enterEditMode });
</N8nText> </N8nText>
</div> </div>
<div v-else-if="isTrimmedManualExecutionDataItem" :class="$style.center">
<N8nText bold color="text-dark" size="large">
{{ i18n.baseText('runData.trimmedData.title') }}
</N8nText>
<N8nText>
{{ i18n.baseText('runData.trimmedData.message') }}
</N8nText>
<N8nButton size="small" @click="onExecutionHistoryNavigate">
<N8nRoute :to="`/workflow/${workflowsStore.workflowId}/executions`">
{{ i18n.baseText('runData.trimmedData.button') }}
</N8nRoute>
</N8nButton>
</div>
<div v-else-if="hasNodeRun && isArtificialRecoveredEventItem" :class="$style.center"> <div v-else-if="hasNodeRun && isArtificialRecoveredEventItem" :class="$style.center">
<slot name="recovered-artificial-output-data"></slot> <slot name="recovered-artificial-output-data"></slot>
</div> </div>

View file

@ -1662,6 +1662,9 @@
"runData.aiContentBlock.tokens": "{count} Tokens", "runData.aiContentBlock.tokens": "{count} Tokens",
"runData.aiContentBlock.tokens.prompt": "Prompt:", "runData.aiContentBlock.tokens.prompt": "Prompt:",
"runData.aiContentBlock.tokens.completion": "Completion:", "runData.aiContentBlock.tokens.completion": "Completion:",
"runData.trimmedData.title": "Data too large to display",
"runData.trimmedData.message": "The data is too large to be shown here. View the full details in 'Executions' tab.",
"runData.trimmedData.button": "See execution",
"saveButton.save": "@:_reusableBaseText.save", "saveButton.save": "@:_reusableBaseText.save",
"saveButton.saved": "Saved", "saveButton.saved": "Saved",
"saveWorkflowButton.hint": "Save workflow", "saveWorkflowButton.hint": "Save workflow",

View file

@ -1,10 +1,11 @@
import type { SamlPreferences } from '@n8n/api-types';
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature } from '@/constants';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import * as ssoApi from '@/api/sso'; import * as ssoApi from '@/api/sso';
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface'; import type { SamlPreferencesExtractedData } from '@/Interface';
import { updateCurrentUser } from '@/api/users'; import { updateCurrentUser } from '@/api/users';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
@ -64,7 +65,7 @@ export const useSSOStore = defineStore('sso', () => {
state.samlConfig = samlConfig; state.samlConfig = samlConfig;
return samlConfig; return samlConfig;
}; };
const saveSamlConfig = async (config: SamlPreferences) => const saveSamlConfig = async (config: Partial<SamlPreferences>) =>
await ssoApi.saveSamlConfig(rootStore.restApiContext, config); await ssoApi.saveSamlConfig(rootStore.restApiContext, config);
const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext); const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext);

View file

@ -1,3 +1,4 @@
import type { SamlPreferences } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { within, waitFor } from '@testing-library/vue'; import { within, waitFor } from '@testing-library/vue';
import { mockedStore, retry } from '@/__tests__/utils'; import { mockedStore, retry } from '@/__tests__/utils';
@ -11,6 +12,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature } from '@/constants';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import type { SamlPreferencesExtractedData } from '@/Interface';
const renderView = createComponentRenderer(SettingsSso); const renderView = createComponentRenderer(SettingsSso);
@ -20,7 +22,7 @@ const samlConfig = {
'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN', 'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN',
entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata', entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata',
returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs', returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs',
}; } as SamlPreferences & SamlPreferencesExtractedData;
const telemetryTrack = vi.fn(); const telemetryTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({ vi.mock('@/composables/useTelemetry', () => ({
@ -133,7 +135,7 @@ describe('SettingsSso View', () => {
const urlinput = getByTestId('sso-provider-url'); const urlinput = getByTestId('sso-provider-url');
expect(urlinput).toBeVisible(); expect(urlinput).toBeVisible();
await userEvent.type(urlinput, samlConfig.metadataUrl); await userEvent.type(urlinput, samlConfig.metadataUrl!);
expect(saveButton).not.toBeDisabled(); expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton); await userEvent.click(saveButton);
@ -172,7 +174,7 @@ describe('SettingsSso View', () => {
const xmlInput = getByTestId('sso-provider-xml'); const xmlInput = getByTestId('sso-provider-xml');
expect(xmlInput).toBeVisible(); expect(xmlInput).toBeVisible();
await userEvent.type(xmlInput, samlConfig.metadata); await userEvent.type(xmlInput, samlConfig.metadata!);
expect(saveButton).not.toBeDisabled(); expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton); await userEvent.click(saveButton);

View file

@ -1007,7 +1007,7 @@ export class HttpRequestV3 implements INodeType {
[ [
{ {
message: message:
'To split the contents of data into separate items for easier processing, add a Spilt Out node after this one', 'To split the contents of data into separate items for easier processing, add a Split Out node after this one',
location: 'outputPane', location: 'outputPane',
}, },
], ],

View file

@ -293,8 +293,8 @@ export class Supabase implements INodeType {
if (keys.length !== 0) { if (keys.length !== 0) {
if (matchType === 'allFilters') { if (matchType === 'allFilters') {
const data = keys.reduce((obj, value) => buildQuery(obj, value), {}); const data = keys.map((key) => buildOrQuery(key));
Object.assign(qs, data); Object.assign(qs, { and: `(${data.join(',')})` });
} }
if (matchType === 'anyFilter') { if (matchType === 'anyFilter') {
const data = keys.map((key) => buildOrQuery(key)); const data = keys.map((key) => buildOrQuery(key));

View file

@ -0,0 +1,99 @@
import { mock } from 'jest-mock-extended';
import { get } from 'lodash';
import {
type IDataObject,
type IExecuteFunctions,
type IGetNodeParameterOptions,
type INodeExecutionData,
type IPairedItemData,
NodeOperationError,
} from 'n8n-workflow';
import * as utils from '../GenericFunctions';
import { Supabase } from '../Supabase.node';
describe('Test Supabase Node', () => {
const node = new Supabase();
const input = [{ json: {} }];
const createMockExecuteFunction = (
nodeParameters: IDataObject,
continueOnFail: boolean = false,
) => {
const fakeExecuteFunction = {
getNodeParameter(
parameterName: string,
itemIndex: number,
fallbackValue?: IDataObject | undefined,
options?: IGetNodeParameterOptions | undefined,
) {
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
const parameterValue = get(nodeParameters, parameter, fallbackValue);
if ((parameterValue as IDataObject)?.nodeOperationError) {
throw new NodeOperationError(mock(), 'Get Options Error', { itemIndex });
}
return parameterValue;
},
getNode() {
return node;
},
continueOnFail: () => continueOnFail,
getInputData: () => input,
helpers: {
constructExecutionMetaData: (
_inputData: INodeExecutionData[],
_options: { itemData: IPairedItemData | IPairedItemData[] },
) => [],
returnJsonArray: (_jsonData: IDataObject | IDataObject[]) => [],
},
} as unknown as IExecuteFunctions;
return fakeExecuteFunction;
};
it('should allow filtering on the same field multiple times', async () => {
const supabaseApiRequest = jest
.spyOn(utils, 'supabaseApiRequest')
.mockImplementation(async () => {
return [];
});
const fakeExecuteFunction = createMockExecuteFunction({
resource: 'row',
operation: 'getAll',
returnAll: true,
filterType: 'manual',
matchType: 'allFilters',
tableId: 'my_table',
filters: {
conditions: [
{
condition: 'gt',
keyName: 'created_at',
keyValue: '2025-01-02 08:03:43.952051+00',
},
{
condition: 'lt',
keyName: 'created_at',
keyValue: '2025-01-02 08:07:36.102231+00',
},
],
},
});
await node.execute.call(fakeExecuteFunction);
expect(supabaseApiRequest).toHaveBeenCalledWith(
'GET',
'/my_table',
{},
{
and: '(created_at.gt.2025-01-02 08:03:43.952051+00,created_at.lt.2025-01-02 08:07:36.102231+00)',
offset: 0,
},
);
});
});

View file

@ -41,7 +41,7 @@
"@types/xml2js": "catalog:" "@types/xml2js": "catalog:"
}, },
"dependencies": { "dependencies": {
"@n8n/tournament": "1.0.5", "@n8n/tournament": "1.0.6",
"@n8n_io/riot-tmpl": "4.0.0", "@n8n_io/riot-tmpl": "4.0.0",
"ast-types": "0.15.2", "ast-types": "0.15.2",
"axios": "catalog:", "axios": "catalog:",

View file

@ -88,3 +88,10 @@ export const LANGCHAIN_CUSTOM_TOOLS = [
export const SEND_AND_WAIT_OPERATION = 'sendAndWait'; export const SEND_AND_WAIT_OPERATION = 'sendAndWait';
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt'; export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
export const AI_TRANSFORM_JS_CODE = 'jsCode'; export const AI_TRANSFORM_JS_CODE = 'jsCode';
/**
* Key for an item standing in for a manual execution data item too large to be
* sent live via pubsub. See {@link TRIMMED_TASK_DATA_CONNECTIONS} in constants
* in `cli` package.
*/
export const TRIMMED_TASK_DATA_CONNECTIONS_KEY = '__isTrimmedManualExecutionDataItem';

View file

@ -2118,6 +2118,7 @@ export interface IRun {
// The RunData, ExecuteData and WaitForExecution contain often the same data. // The RunData, ExecuteData and WaitForExecution contain often the same data.
export interface IRunExecutionData { export interface IRunExecutionData {
startData?: { startData?: {
startNodes?: StartNodeData[];
destinationNode?: string; destinationNode?: string;
runNodeFilter?: string[]; runNodeFilter?: string[];
}; };
@ -2141,6 +2142,15 @@ export interface IRunExecutionData {
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
waitTill?: Date; waitTill?: Date;
pushRef?: string; pushRef?: string;
/** Whether this execution was started by a test webhook call. */
isTestWebhook?: boolean;
/** Data needed for a worker to run a manual execution. */
manualData?: Pick<
IWorkflowExecutionDataProcess,
'partialExecutionVersion' | 'dirtyNodeNames' | 'triggerToStartFrom' | 'userId'
>;
} }
export interface IRunData { export interface IRunData {

View file

@ -1937,8 +1937,8 @@ importers:
packages/workflow: packages/workflow:
dependencies: dependencies:
'@n8n/tournament': '@n8n/tournament':
specifier: 1.0.5 specifier: 1.0.6
version: 1.0.5 version: 1.0.6
'@n8n_io/riot-tmpl': '@n8n_io/riot-tmpl':
specifier: 4.0.0 specifier: 4.0.0
version: 4.0.0 version: 4.0.0
@ -4259,8 +4259,8 @@ packages:
resolution: {integrity: sha512-rbnMnSdEwq2yuYMgzOQ4jTXm+oH7yjN/0ISfB/7O6pUcEPsZt9UW60BYfQ1WWHkKa/evI8vgER2zV5/RC1BupQ==} resolution: {integrity: sha512-rbnMnSdEwq2yuYMgzOQ4jTXm+oH7yjN/0ISfB/7O6pUcEPsZt9UW60BYfQ1WWHkKa/evI8vgER2zV5/RC1BupQ==}
engines: {node: '>=18.10'} engines: {node: '>=18.10'}
'@n8n/tournament@1.0.5': '@n8n/tournament@1.0.6':
resolution: {integrity: sha512-IPBHa7gC0wwHVct/dnBquHz+uMCDZaZ05cor1D/rjlwaOe/PVu5mtoZaPHYuR98R3W1/IyxC5PuBd0JizDP9gg==} resolution: {integrity: sha512-UGSxYXXVuOX0yL6HTLBStKYwLIa0+JmRKiSZSCMcM2s2Wax984KWT6XIA1TR/27i7yYpDk1MY14KsTPnuEp27A==}
engines: {node: '>=20.15', pnpm: '>=9.5'} engines: {node: '>=20.15', pnpm: '>=9.5'}
'@n8n/typeorm@0.3.20-12': '@n8n/typeorm@0.3.20-12':
@ -16484,7 +16484,7 @@ snapshots:
'@types/retry': 0.12.5 '@types/retry': 0.12.5
retry: 0.13.1 retry: 0.13.1
'@n8n/tournament@1.0.5': '@n8n/tournament@1.0.6':
dependencies: dependencies:
'@n8n_io/riot-tmpl': 4.0.1 '@n8n_io/riot-tmpl': 4.0.1
ast-types: 0.16.1 ast-types: 0.16.1
@ -19401,7 +19401,7 @@ snapshots:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
is-nan: 1.3.2 is-nan: 1.3.2
object-is: 1.1.5 object-is: 1.1.6
object.assign: 4.1.5 object.assign: 4.1.5
util: 0.12.5 util: 0.12.5
@ -20867,7 +20867,7 @@ snapshots:
enzyme-shallow-equal@1.0.7: enzyme-shallow-equal@1.0.7:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
object-is: 1.1.5 object-is: 1.1.6
enzyme@3.11.0: enzyme@3.11.0:
dependencies: dependencies:
@ -25609,7 +25609,7 @@ snapshots:
regexp.prototype.flags@1.5.0: regexp.prototype.flags@1.5.0:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
define-properties: 1.2.0 define-properties: 1.2.1
functions-have-names: 1.2.3 functions-have-names: 1.2.3
regexp.prototype.flags@1.5.2: regexp.prototype.flags@1.5.2: