mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' of https://github.com/n8n-io/n8n into node-2015-google-calendar-confusing-errors
This commit is contained in:
commit
d187b11044
|
@ -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 }}');
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
6
packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
|
export class SamlAcsDto extends Z.class({
|
||||||
|
RelayState: z.string().optional(),
|
||||||
|
}) {}
|
50
packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts
Normal file
50
packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts
Normal 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(''),
|
||||||
|
}) {}
|
6
packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
|
export class SamlToggleDto extends Z.class({
|
||||||
|
loginEnabled: z.boolean(),
|
||||||
|
}) {}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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'),
|
||||||
|
}) {}
|
|
@ -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`
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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: () => {},
|
||||||
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
118
packages/@n8n/task-runner/src/task-state.ts
Normal file
118
packages/@n8n/task-runner/src/task-state.ts
Normal 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)}`);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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>());
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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' });
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
5
packages/cli/src/sso.ee/saml/types.ts
Normal file
5
packages/cli/src/sso.ee/saml/types.ts
Normal 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;
|
|
@ -1 +0,0 @@
|
||||||
export type SamlLoginBinding = 'post' | 'redirect';
|
|
|
@ -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;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
>;
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
export interface SamlAttributeMapping {
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
userPrincipalName: string;
|
|
||||||
}
|
|
|
@ -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 = '';
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
export interface SamlUserAttributes {
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
userPrincipalName: string;
|
|
||||||
}
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)) {
|
||||||
|
|
|
@ -69,6 +69,4 @@ export declare namespace WorkflowRequest {
|
||||||
{},
|
{},
|
||||||
{ destinationProjectId: string }
|
{ destinationProjectId: string }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!');
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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:",
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue