diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index feb39f1f4f7f2..4e77fbd580fb3 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,6 +1,8 @@ name: Chromatic on: + schedule: + - cron: '0 0 * * *' workflow_dispatch: pull_request_review: types: [submitted] @@ -70,7 +72,7 @@ jobs: exitZeroOnChanges: false - name: Success comment - if: steps.chromatic_tests.outcome == 'success' + if: steps.chromatic_tests.outcome == 'success' && github.ref != 'refs/heads/master' uses: peter-evans/create-or-update-comment@v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} @@ -80,7 +82,7 @@ jobs: :white_check_mark: No visual regressions found. - name: Fail comment - if: steps.chromatic_tests.outcome != 'success' + if: steps.chromatic_tests.outcome != 'success' && github.ref != 'refs/heads/master' uses: peter-evans/create-or-update-comment@v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index bca99ebb65ed8..f6e5e773cb57f 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -106,7 +106,7 @@ jobs: - name: Test MariaDB working-directory: packages/cli - run: pnpm test:mariadb --testTimeout 20000 + run: pnpm test:mariadb --testTimeout 30000 postgres: name: Postgres diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml index 60bf593e824ef..6475dcccc58b9 100644 --- a/.github/workflows/units-tests-reusable.yml +++ b/.github/workflows/units-tests-reusable.yml @@ -49,7 +49,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup build cache - if: inputs.collectCoverage != true uses: rharkor/caching-for-turbo@v1.5 - name: Build diff --git a/LICENSE.md b/LICENSE.md index aab68b6d9301b..f85f59baa9065 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -3,9 +3,11 @@ Portions of this software are licensed as follows: - Content of branches other than the main branch (i.e. "master") are not licensed. -- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License. - To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License - specifically allowing you access to such source code files and as defined in "LICENSE_EE.md". +- Source code files that contain ".ee." in their filename or ".ee" in their dirname are NOT licensed under + the Sustainable Use License. + To use source code files that contain ".ee." in their filename or ".ee" in their dirname you must hold a + valid n8n Enterprise License specifically allowing you access to such source code files and as defined + in "LICENSE_EE.md". - All third party components incorporated into the n8n Software are licensed under the original license provided by the owner of the applicable component. - Content outside of the above mentioned files or restrictions is available under the "Sustainable Use diff --git a/README.md b/README.md index c41a5e5ac863d..cc7d596f61171 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Try n8n instantly with [npx](https://docs.n8n.io/hosting/installation/npm/) (req Or deploy with [Docker](https://docs.n8n.io/hosting/installation/docker/): -`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8n-io/n8n` +`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8nio/n8n` Access the editor at http://localhost:5678 diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 3e1b2fd46a9f7..09d7a341cea9a 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -29,7 +29,11 @@ export const getAddProjectButton = () => { return cy.get('@button'); }; - +export const getAddFirstProjectButton = () => cy.getByTestId('add-first-project-button'); +export const getIconPickerButton = () => cy.getByTestId('icon-picker-button'); +export const getIconPickerTab = (tab: string) => cy.getByTestId('icon-picker-tabs').contains(tab); +export const getIconPickerIcons = () => cy.getByTestId('icon-picker-icon'); +export const getIconPickerEmojis = () => cy.getByTestId('icon-picker-emoji'); // export const getAddProjectButton = () => // cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index e19959453f847..46448b496662b 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -41,7 +41,9 @@ describe('Data mapping', () => { ndv.actions.mapDataFromHeader(1, 'value'); ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }}'); ndv.getters.inlineExpressionEditorInput().type('{esc}'); - ndv.getters.parameterExpressionPreview('value').should('include.text', '2024'); + ndv.getters + .parameterExpressionPreview('value') + .should('include.text', new Date().getFullYear()); ndv.actions.mapDataFromHeader(2, 'value'); ndv.getters diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 327bff4f93589..efd18bca74bcb 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -15,7 +15,7 @@ import { NDV, MainSidebar, } from '../pages'; -import { clearNotifications } from '../pages/notifications'; +import { clearNotifications, successToast } from '../pages/notifications'; import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); @@ -830,4 +830,23 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('not.have.length'); }); }); + + it('should set and update project icon', () => { + const DEFAULT_ICON = 'fa-layer-group'; + const NEW_PROJECT_NAME = 'Test Project'; + + cy.signinAsAdmin(); + cy.visit(workflowsPage.url); + projects.createProject(NEW_PROJECT_NAME); + // New project should have default icon + projects.getIconPickerButton().find('svg').should('have.class', DEFAULT_ICON); + // Choose another icon + projects.getIconPickerButton().click(); + projects.getIconPickerTab('Emojis').click(); + projects.getIconPickerEmojis().first().click(); + // Project should be updated with new icon + successToast().contains('Project icon updated successfully'); + projects.getIconPickerButton().should('contain', '😀'); + projects.getMenuItems().contains(NEW_PROJECT_NAME).should('contain', '😀'); + }); }); diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index c14e18992236e..299986a5c9cc7 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -27,6 +27,6 @@ "dependencies": { "xss": "catalog:", "zod": "catalog:", - "zod-class": "0.0.15" + "zod-class": "0.0.16" } } diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts new file mode 100644 index 0000000000000..568900e409f7f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts @@ -0,0 +1,36 @@ +import { AiApplySuggestionRequestDto } from '../ai-apply-suggestion-request.dto'; + +describe('AiApplySuggestionRequestDto', () => { + it('should validate a valid suggestion application request', () => { + const validRequest = { + sessionId: 'session-123', + suggestionId: 'suggestion-456', + }; + + const result = AiApplySuggestionRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if sessionId is missing', () => { + const invalidRequest = { + suggestionId: 'suggestion-456', + }; + + const result = AiApplySuggestionRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['sessionId']); + }); + + it('should fail if suggestionId is missing', () => { + const invalidRequest = { + sessionId: 'session-123', + }; + + const result = AiApplySuggestionRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['suggestionId']); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts new file mode 100644 index 0000000000000..a87eb5f3a4c81 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts @@ -0,0 +1,252 @@ +import { AiAskRequestDto } from '../ai-ask-request.dto'; + +describe('AiAskRequestDto', () => { + const validRequest = { + question: 'How can I improve this workflow?', + context: { + schema: [ + { + nodeName: 'TestNode', + schema: { + type: 'string', + key: 'testKey', + value: 'testValue', + path: '/test/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'inputKey', + value: [ + { + type: 'string', + key: 'nestedKey', + value: 'nestedValue', + path: '/nested/path', + }, + ], + path: '/input/path', + }, + }, + pushRef: 'push-123', + ndvPushRef: 'ndv-push-456', + }, + forNode: 'TestWorkflowNode', + }; + + it('should validate a valid AI ask request', () => { + const result = AiAskRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if question is missing', () => { + const invalidRequest = { + ...validRequest, + question: undefined, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['question']); + }); + + it('should fail if context is invalid', () => { + const invalidRequest = { + ...validRequest, + context: { + ...validRequest.context, + schema: [ + { + nodeName: 'TestNode', + schema: { + type: 'invalid-type', // Invalid type + value: 'testValue', + path: '/test/path', + }, + }, + ], + }, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + }); + + it('should fail if forNode is missing', () => { + const invalidRequest = { + ...validRequest, + forNode: undefined, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['forNode']); + }); + + it('should validate all possible schema types', () => { + const allTypesRequest = { + question: 'Test all possible types', + context: { + schema: [ + { + nodeName: 'AllTypesNode', + schema: { + type: 'object', + key: 'typesRoot', + value: [ + { type: 'string', key: 'stringType', value: 'string', path: '/types/string' }, + { type: 'number', key: 'numberType', value: 'number', path: '/types/number' }, + { type: 'boolean', key: 'booleanType', value: 'boolean', path: '/types/boolean' }, + { type: 'bigint', key: 'bigintType', value: 'bigint', path: '/types/bigint' }, + { type: 'symbol', key: 'symbolType', value: 'symbol', path: '/types/symbol' }, + { type: 'array', key: 'arrayType', value: [], path: '/types/array' }, + { type: 'object', key: 'objectType', value: [], path: '/types/object' }, + { + type: 'function', + key: 'functionType', + value: 'function', + path: '/types/function', + }, + { type: 'null', key: 'nullType', value: 'null', path: '/types/null' }, + { + type: 'undefined', + key: 'undefinedType', + value: 'undefined', + path: '/types/undefined', + }, + ], + path: '/types/root', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-types-123', + ndvPushRef: 'ndv-push-types-456', + }, + forNode: 'TypeCheckNode', + }; + + const result = AiAskRequestDto.safeParse(allTypesRequest); + expect(result.success).toBe(true); + }); + + it('should fail with invalid type', () => { + const invalidTypeRequest = { + question: 'Test invalid type', + context: { + schema: [ + { + nodeName: 'InvalidTypeNode', + schema: { + type: 'invalid-type', // This should fail + key: 'invalidKey', + value: 'invalidValue', + path: '/invalid/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-invalid-123', + ndvPushRef: 'ndv-push-invalid-456', + }, + forNode: 'InvalidTypeNode', + }; + + const result = AiAskRequestDto.safeParse(invalidTypeRequest); + expect(result.success).toBe(false); + }); + + it('should validate multiple schema entries', () => { + const multiSchemaRequest = { + question: 'Multiple schema test', + context: { + schema: [ + { + nodeName: 'FirstNode', + schema: { + type: 'string', + key: 'firstKey', + value: 'firstValue', + path: '/first/path', + }, + }, + { + nodeName: 'SecondNode', + schema: { + type: 'object', + key: 'secondKey', + value: [ + { + type: 'number', + key: 'nestedKey', + value: 'nestedValue', + path: '/second/nested/path', + }, + ], + path: '/second/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-multi-123', + ndvPushRef: 'ndv-push-multi-456', + }, + forNode: 'MultiSchemaNode', + }; + + const result = AiAskRequestDto.safeParse(multiSchemaRequest); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts new file mode 100644 index 0000000000000..ce1ccffac5f95 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts @@ -0,0 +1,34 @@ +import { AiChatRequestDto } from '../ai-chat-request.dto'; + +describe('AiChatRequestDto', () => { + it('should validate a request with a payload and session ID', () => { + const validRequest = { + payload: { someKey: 'someValue' }, + sessionId: 'session-123', + }; + + const result = AiChatRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should validate a request with only a payload', () => { + const validRequest = { + payload: { complexObject: { nested: 'value' } }, + }; + + const result = AiChatRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if payload is missing', () => { + const invalidRequest = { + sessionId: 'session-123', + }; + + const result = AiChatRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts new file mode 100644 index 0000000000000..2b61eeaee989f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts @@ -0,0 +1,32 @@ +import { nanoId } from 'minifaker'; + +import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto'; +import 'minifaker/locales/en'; + +describe('AiChatRequestDto', () => { + it('should succeed if projectId is a valid nanoid', () => { + const validRequest = { + projectId: nanoId.nanoid(), + }; + + const result = AiFreeCreditsRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should succeed if no projectId is sent', () => { + const result = AiFreeCreditsRequestDto.safeParse({}); + + expect(result.success).toBe(true); + }); + + it('should fail is projectId invalid value', () => { + const validRequest = { + projectId: '', + }; + + const result = AiFreeCreditsRequestDto.safeParse(validRequest); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts new file mode 100644 index 0000000000000..cc808dfd24a66 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiApplySuggestionRequestDto extends Z.class({ + sessionId: z.string(), + suggestionId: z.string(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts new file mode 100644 index 0000000000000..9039243e051b2 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts @@ -0,0 +1,53 @@ +import type { AiAssistantSDK, SchemaType } from '@n8n_io/ai-assistant-sdk'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +// Note: This is copied from the sdk, since this type is not exported +type Schema = { + type: SchemaType; + key?: string; + value: string | Schema[]; + path: string; +}; + +// Create a lazy validator to handle the recursive type +const schemaValidator: z.ZodType = z.lazy(() => + z.object({ + type: z.enum([ + 'string', + 'number', + 'boolean', + 'bigint', + 'symbol', + 'array', + 'object', + 'function', + 'null', + 'undefined', + ]), + key: z.string().optional(), + value: z.union([z.string(), z.lazy(() => schemaValidator.array())]), + path: z.string(), + }), +); + +export class AiAskRequestDto + extends Z.class({ + question: z.string(), + context: z.object({ + schema: z.array( + z.object({ + nodeName: z.string(), + schema: schemaValidator, + }), + ), + inputSchema: z.object({ + nodeName: z.string(), + schema: schemaValidator, + }), + pushRef: z.string(), + ndvPushRef: z.string(), + }), + forNode: z.string(), + }) + implements AiAssistantSDK.AskAiRequestPayload {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts new file mode 100644 index 0000000000000..59e7a26aa3628 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts @@ -0,0 +1,10 @@ +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiChatRequestDto + extends Z.class({ + payload: z.object({}).passthrough(), // Allow any object shape + sessionId: z.string().optional(), + }) + implements AiAssistantSDK.ChatRequestPayload {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts new file mode 100644 index 0000000000000..9f9120d417da7 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiFreeCreditsRequestDto extends Z.class({ + projectId: z.string().min(1).optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts new file mode 100644 index 0000000000000..f222f1d93ecd9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts @@ -0,0 +1,93 @@ +import { LoginRequestDto } from '../login-request.dto'; + +describe('LoginRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid login request', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaCode: '123456', + }, + }, + { + name: 'login request without optional MFA', + request: { + email: 'test@example.com', + password: 'securePassword123', + }, + }, + { + name: 'login request with both mfaCode and mfaRecoveryCode', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaCode: '123456', + mfaRecoveryCode: 'recovery-code-123', + }, + }, + { + name: 'login request with only mfaRecoveryCode', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaRecoveryCode: 'recovery-code-123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = LoginRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: { + email: 'invalid-email', + password: 'securePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'empty password', + request: { + email: 'test@example.com', + password: '', + }, + expectedErrorPath: ['password'], + }, + { + name: 'missing email', + request: { + password: 'securePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'missing password', + request: { + email: 'test@example.com', + }, + expectedErrorPath: ['password'], + }, + { + name: 'whitespace in email and password', + request: { + email: ' test@example.com ', + password: ' securePassword123 ', + }, + expectedErrorPath: ['email'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = LoginRequestDto.safeParse(request); + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts b/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts new file mode 100644 index 0000000000000..218fe9107a43e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts @@ -0,0 +1,87 @@ +import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto'; + +describe('ResolveSignupTokenQueryDto', () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + + describe('Valid requests', () => { + test.each([ + { + name: 'standard UUID', + request: { + inviterId: validUuid, + inviteeId: validUuid, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResolveSignupTokenQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid inviterId UUID', + request: { + inviterId: 'not-a-valid-uuid', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'invalid inviteeId UUID', + request: { + inviterId: validUuid, + inviteeId: 'not-a-valid-uuid', + }, + expectedErrorPath: ['inviteeId'], + }, + { + name: 'missing inviterId', + request: { + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'missing inviteeId', + request: { + inviterId: validUuid, + }, + expectedErrorPath: ['inviteeId'], + }, + { + name: 'UUID with invalid characters', + request: { + inviterId: '123e4567-e89b-12d3-a456-42661417400G', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'UUID too long', + request: { + inviterId: '123e4567-e89b-12d3-a456-426614174001234', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'UUID too short', + request: { + inviterId: '123e4567-e89b-12d3-a456', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResolveSignupTokenQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts new file mode 100644 index 0000000000000..894263992c6e0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class LoginRequestDto extends Z.class({ + email: z.string().email(), + password: z.string().min(1), + mfaCode: z.string().optional(), + mfaRecoveryCode: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts b/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts new file mode 100644 index 0000000000000..768202ff04ed9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ResolveSignupTokenQueryDto extends Z.class({ + inviterId: z.string().uuid(), + inviteeId: z.string().uuid(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts new file mode 100644 index 0000000000000..0fa074b97d9a8 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts @@ -0,0 +1,55 @@ +import { CredentialsGetManyRequestQuery } from '../credentials-get-many-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + test.each([ + { field: 'includeScopes', value: 'true' }, + { field: 'includeScopes', value: 'false' }, + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeScopes', value: true }, + { field: 'includeScopes', value: false }, + { field: 'includeScopes', value: 'invalid' }, + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts new file mode 100644 index 0000000000000..274b00b759e19 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts @@ -0,0 +1,52 @@ +import { CredentialsGetOneRequestQuery } from '../credentials-get-one-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + // defaults to false + expect(result.data?.includeData).toBe(false); + }); + + test.each([ + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts new file mode 100644 index 0000000000000..47332ca7f9c6a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts @@ -0,0 +1,22 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetManyRequestQuery extends Z.class({ + /** + * Adds the `scopes` field to each credential which includes all scopes the + * requesting user has in relation to the credential, e.g. + * ['credential:read', 'credential:update'] + */ + includeScopes: booleanFromString.optional(), + + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + * + * This switches `includeScopes` to true to be able to check for the scopes + */ + includeData: booleanFromString.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts new file mode 100644 index 0000000000000..ad790014e8efe --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts @@ -0,0 +1,13 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetOneRequestQuery extends Z.class({ + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + */ + includeData: booleanFromString.optional().default('false'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts new file mode 100644 index 0000000000000..eb451e5b092cd --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts @@ -0,0 +1,81 @@ +import { ActionResultRequestDto } from '../action-result-request.dto'; + +describe('ActionResultRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with payload', + request: { + ...baseValidRequest, + payload: { key: 'value' }, + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ActionResultRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing handler', + request: { + path: '/test/path', + currentNodeParameters: {}, + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + }, + expectedErrorPath: ['handler'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ActionResultRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts new file mode 100644 index 0000000000000..28c5534cc758a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts @@ -0,0 +1,90 @@ +import { OptionsRequestDto } from '../options-request.dto'; + +describe('OptionsRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with method name', + request: { + ...baseValidRequest, + methodName: 'testMethod', + }, + }, + { + name: 'request with load options', + request: { + ...baseValidRequest, + loadOptions: { + routing: { + operations: { someOperation: 'test' }, + output: { someOutput: 'test' }, + request: { someRequest: 'test' }, + }, + }, + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = OptionsRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing node type and version', + request: { + path: '/test/path', + }, + expectedErrorPath: ['nodeTypeAndVersion'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = OptionsRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts new file mode 100644 index 0000000000000..d64f31dec23da --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts @@ -0,0 +1,95 @@ +import { ResourceLocatorRequestDto } from '../resource-locator-request.dto'; + +describe('ResourceLocatorRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with filter', + request: { + ...baseValidRequest, + filter: 'testFilter', + }, + }, + { + name: 'request with pagination token', + request: { + ...baseValidRequest, + paginationToken: 'token123', + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + { + name: 'request with a semver node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 1.1 }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResourceLocatorRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing method name', + request: { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }, + expectedErrorPath: ['methodName'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResourceLocatorRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts new file mode 100644 index 0000000000000..2370177ab0ae5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts @@ -0,0 +1,74 @@ +import { ResourceMapperFieldsRequestDto } from '../resource-mapper-fields-request.dto'; + +describe('ResourceMapperFieldsRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResourceMapperFieldsRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing method name', + request: { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }, + expectedErrorPath: ['methodName'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResourceMapperFieldsRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts new file mode 100644 index 0000000000000..d6f867af6d3e3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts @@ -0,0 +1,11 @@ +import type { IDataObject } from 'n8n-workflow'; +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ActionResultRequestDto extends BaseDynamicParametersRequestDto.extend({ + handler: z.string(), + payload: z + .union([z.object({}).catchall(z.any()) satisfies z.ZodType, z.string()]) + .optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts new file mode 100644 index 0000000000000..66b9cd7629595 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts @@ -0,0 +1,18 @@ +import type { INodeCredentials, INodeParameters, INodeTypeNameVersion } from 'n8n-workflow'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { nodeVersionSchema } from '../../schemas/nodeVersion.schema'; + +export class BaseDynamicParametersRequestDto extends Z.class({ + path: z.string(), + nodeTypeAndVersion: z.object({ + name: z.string(), + version: nodeVersionSchema, + }) satisfies z.ZodType, + currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType, + methodName: z.string().optional(), + credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType< + INodeCredentials | undefined + >, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts new file mode 100644 index 0000000000000..b9d34ef75d5f4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts @@ -0,0 +1,18 @@ +import type { ILoadOptions } from 'n8n-workflow'; +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class OptionsRequestDto extends BaseDynamicParametersRequestDto.extend({ + loadOptions: z + .object({ + routing: z + .object({ + operations: z.any().optional(), + output: z.any().optional(), + request: z.any().optional(), + }) + .optional(), + }) + .optional() as z.ZodType, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts new file mode 100644 index 0000000000000..ac8e8df274544 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ResourceLocatorRequestDto extends BaseDynamicParametersRequestDto.extend({ + methodName: z.string(), + filter: z.string().optional(), + paginationToken: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts new file mode 100644 index 0000000000000..3c6d00eb3c390 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ResourceMapperFieldsRequestDto extends BaseDynamicParametersRequestDto.extend({ + methodName: z.string(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 97d5d38459b4d..6dbfe173611a6 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -1,6 +1,37 @@ +export { AiAskRequestDto } from './ai/ai-ask-request.dto'; +export { AiChatRequestDto } from './ai/ai-chat-request.dto'; +export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; +export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto'; + +export { LoginRequestDto } from './auth/login-request.dto'; +export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; + +export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto'; +export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto'; +export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto'; +export { ActionResultRequestDto } from './dynamic-node-parameters/action-result-request.dto'; + +export { InviteUsersRequestDto } from './invitation/invite-users-request.dto'; +export { AcceptInvitationRequestDto } from './invitation/accept-invitation-request.dto'; + +export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto'; +export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto'; + +export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto'; +export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.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 { RoleChangeRequestDto } from './user/role-change-request.dto'; export { SettingsUpdateRequestDto } from './user/settings-update-request.dto'; export { UserUpdateRequestDto } from './user/user-update-request.dto'; + export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto'; + export { VariableListRequestDto } from './variables/variables-list-request.dto'; +export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto'; +export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts new file mode 100644 index 0000000000000..f78de8ab6d00c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts @@ -0,0 +1,94 @@ +import { AcceptInvitationRequestDto } from '../accept-invitation-request.dto'; + +describe('AcceptInvitationRequestDto', () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid invitation acceptance', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = AcceptInvitationRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing inviterId', + request: { + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'invalid inviterId', + request: { + inviterId: 'not-a-valid-uuid', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'missing first name', + request: { + inviterId: validUuid, + firstName: '', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['firstName'], + }, + { + name: 'missing last name', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: '', + password: 'SecurePassword123', + }, + expectedErrorPath: ['lastName'], + }, + { + name: 'password too short', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = AcceptInvitationRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts new file mode 100644 index 0000000000000..f47a138ed5ebc --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts @@ -0,0 +1,60 @@ +import { InviteUsersRequestDto } from '../invite-users-request.dto'; + +describe('InviteUsersRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'empty array', + request: [], + }, + { + name: 'single user invitation with default role', + request: [{ email: 'user@example.com' }], + }, + { + name: 'multiple user invitations with different roles', + request: [ + { email: 'user1@example.com', role: 'global:member' }, + { email: 'user2@example.com', role: 'global:admin' }, + ], + }, + ])('should validate $name', ({ request }) => { + const result = InviteUsersRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + + it('should default role to global:member', () => { + const result = InviteUsersRequestDto.safeParse([{ email: 'user@example.com' }]); + expect(result.success).toBe(true); + expect(result.data?.[0].role).toBe('global:member'); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: [{ email: 'invalid-email' }], + expectedErrorPath: [0, 'email'], + }, + { + name: 'invalid role', + request: [ + { + email: 'user@example.com', + role: 'invalid-role', + }, + ], + expectedErrorPath: [0, 'role'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = InviteUsersRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts b/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts new file mode 100644 index 0000000000000..7c93e708ba18a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; + +export class AcceptInvitationRequestDto extends Z.class({ + inviterId: z.string().uuid(), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + password: passwordSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts new file mode 100644 index 0000000000000..9693234c64cde --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +const roleSchema = z.enum(['global:member', 'global:admin']); + +const invitedUserSchema = z.object({ + email: z.string().email(), + role: roleSchema.default('global:member'), +}); + +const invitationsSchema = z.array(invitedUserSchema); + +export class InviteUsersRequestDto extends Array> { + static safeParse(data: unknown) { + return invitationsSchema.safeParse(data); + } +} diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts new file mode 100644 index 0000000000000..97371de16a10c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts @@ -0,0 +1,64 @@ +import { bannerNameSchema } from '../../../schemas/bannerName.schema'; +import { DismissBannerRequestDto } from '../dismiss-banner-request.dto'; + +describe('DismissBannerRequestDto', () => { + describe('Valid requests', () => { + test.each( + bannerNameSchema.options.map((banner) => ({ + name: `valid banner: ${banner}`, + request: { banner }, + })), + )('should validate $name', ({ request }) => { + const result = DismissBannerRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid banner string', + request: { + banner: 'not-a-valid-banner', + }, + expectedErrorPath: ['banner'], + }, + { + name: 'non-string banner', + request: { + banner: 123, + }, + expectedErrorPath: ['banner'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = DismissBannerRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Optional banner', () => { + test('should validate empty request', () => { + const result = DismissBannerRequestDto.safeParse({}); + expect(result.success).toBe(true); + }); + }); + + describe('Exhaustive banner name check', () => { + test('should have all banner names defined', () => { + const expectedBanners = [ + 'V1', + 'TRIAL_OVER', + 'TRIAL', + 'NON_PRODUCTION_LICENSE', + 'EMAIL_CONFIRMATION', + ]; + + expect(bannerNameSchema.options).toEqual(expectedBanners); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts new file mode 100644 index 0000000000000..facf808ec3f34 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts @@ -0,0 +1,93 @@ +import { OwnerSetupRequestDto } from '../owner-setup-request.dto'; + +describe('OwnerSetupRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid setup request', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = OwnerSetupRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: { + email: 'invalid-email', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'missing first name', + request: { + email: 'owner@example.com', + firstName: '', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['firstName'], + }, + { + name: 'missing last name', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: '', + password: 'SecurePassword123', + }, + expectedErrorPath: ['lastName'], + }, + { + name: 'password too short', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without uppercase letter', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'nouppercasepassword123', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = OwnerSetupRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts b/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts new file mode 100644 index 0000000000000..1f42381e7a71a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { bannerNameSchema } from '../../schemas/bannerName.schema'; + +export class DismissBannerRequestDto extends Z.class({ + banner: bannerNameSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts b/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts new file mode 100644 index 0000000000000..ccaa06b18e78a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; + +export class OwnerSetupRequestDto extends Z.class({ + email: z.string().email(), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + password: passwordSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts new file mode 100644 index 0000000000000..86b230ba5ad4d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts @@ -0,0 +1,114 @@ +import { ChangePasswordRequestDto } from '../change-password-request.dto'; + +describe('ChangePasswordRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid password reset with token', + request: { + token: 'valid-reset-token-with-sufficient-length', + password: 'newSecurePassword123', + }, + }, + { + name: 'valid password reset with MFA code', + request: { + token: 'another-valid-reset-token', + password: 'newSecurePassword123', + mfaCode: '123456', + }, + }, + ])('should validate $name', ({ request }) => { + const result = ChangePasswordRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing token', + request: { password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'empty token', + request: { token: '', password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'short token', + request: { token: 'short', password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'missing password', + request: { token: 'valid-reset-token' }, + expectedErrorPath: ['password'], + }, + { + name: 'password too short', + request: { + token: 'valid-reset-token', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password too long', + request: { + token: 'valid-reset-token', + password: 'a'.repeat(65), + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + token: 'valid-reset-token', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without uppercase letter', + request: { + token: 'valid-reset-token', + password: 'nouppercasepassword123', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ChangePasswordRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + + describe('Edge cases', () => { + test('should handle optional MFA code correctly', () => { + const validRequest = { + token: 'valid-reset-token', + password: 'newSecurePassword123', + mfaCode: undefined, + }; + + const result = ChangePasswordRequestDto.safeParse(validRequest); + expect(result.success).toBe(true); + }); + + test('should handle token with special characters', () => { + const validRequest = { + token: 'valid-reset-token-with-special-!@#$%^&*()_+', + password: 'newSecurePassword123', + }; + + const result = ChangePasswordRequestDto.safeParse(validRequest); + expect(result.success).toBe(true); + }); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts new file mode 100644 index 0000000000000..891d52fdad022 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts @@ -0,0 +1,47 @@ +import { ForgotPasswordRequestDto } from '../forgot-password-request.dto'; + +describe('ForgotPasswordRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid email', + request: { email: 'test@example.com' }, + }, + { + name: 'email with subdomain', + request: { email: 'user@sub.example.com' }, + }, + ])('should validate $name', ({ request }) => { + const result = ForgotPasswordRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email format', + request: { email: 'invalid-email' }, + expectedErrorPath: ['email'], + }, + { + name: 'missing email', + request: {}, + expectedErrorPath: ['email'], + }, + { + name: 'empty email', + request: { email: '' }, + expectedErrorPath: ['email'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ForgotPasswordRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts new file mode 100644 index 0000000000000..a2f5881ac81a6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts @@ -0,0 +1,42 @@ +import { ResolvePasswordTokenQueryDto } from '../resolve-password-token-query.dto'; + +describe('ResolvePasswordTokenQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid token', + request: { token: 'valid-reset-token' }, + }, + { + name: 'long token', + request: { token: 'x'.repeat(50) }, + }, + ])('should validate $name', ({ request }) => { + const result = ResolvePasswordTokenQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing token', + request: {}, + expectedErrorPath: ['token'], + }, + { + name: 'empty token', + request: { token: '' }, + expectedErrorPath: ['token'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResolvePasswordTokenQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts new file mode 100644 index 0000000000000..33ef47b3f173f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; +import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema'; + +export class ChangePasswordRequestDto extends Z.class({ + token: passwordResetTokenSchema, + password: passwordSchema, + mfaCode: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts new file mode 100644 index 0000000000000..f6ab3cfac5bcc --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ForgotPasswordRequestDto extends Z.class({ + email: z.string().email(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts new file mode 100644 index 0000000000000..88385df244e8d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema'; + +export class ResolvePasswordTokenQueryDto extends Z.class({ + token: passwordResetTokenSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts new file mode 100644 index 0000000000000..6d11483347deb --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts @@ -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: 'metadata', + 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: 'metadata', + 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(''); + }); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts new file mode 100644 index 0000000000000..2bfbece7d60c6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlAcsDto extends Z.class({ + RelayState: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts new file mode 100644 index 0000000000000..e07504c1b3490 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts @@ -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(''), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts new file mode 100644 index 0000000000000..be07933d06c9c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlToggleDto extends Z.class({ + loginEnabled: z.boolean(), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 1fe0fcd85798c..3ce856d6aded0 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -163,6 +163,10 @@ export interface FrontendSettings { pruneTime: number; licensePruneTime: number; }; + aiCredits: { + enabled: boolean; + credits: number; + }; pruning?: { isEnabled: boolean; maxAge: number; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index d0067f7fff67e..a003d54201982 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -7,3 +7,6 @@ export type * from './user'; export type { Collaborator } from './push/collaboration'; export type { SendWorkerStatusMessage } from './push/worker'; + +export type { BannerName } from './schemas/bannerName.schema'; +export { passwordSchema } from './schemas/password.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts new file mode 100644 index 0000000000000..098db8209697b --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts @@ -0,0 +1,28 @@ +import { nodeVersionSchema } from '../nodeVersion.schema'; + +describe('nodeVersionSchema', () => { + describe('valid versions', () => { + test.each([ + [1, 'single digit'], + [2, 'single digit'], + [1.0, 'major.minor with zero minor'], + [1.2, 'major.minor'], + [10.5, 'major.minor with double digits'], + ])('should accept %s as a valid version (%s)', (version) => { + const validated = nodeVersionSchema.parse(version); + expect(validated).toBe(version); + }); + }); + + describe('invalid versions', () => { + test.each([ + ['not-a-number', 'non-number input'], + ['1.2.3', 'more than two parts'], + ['1.a', 'non-numeric characters'], + ['1.2.3', 'more than two parts as string'], + ])('should reject %s as an invalid version (%s)', (version) => { + const check = () => nodeVersionSchema.parse(version); + expect(check).toThrowError(); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts new file mode 100644 index 0000000000000..c3bcd5f4c85b2 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts @@ -0,0 +1,54 @@ +import { passwordSchema } from '../password.schema'; + +describe('passwordSchema', () => { + test('should throw on empty password', () => { + const check = () => passwordSchema.parse(''); + + expect(check).toThrowError('Password must be 8 to 64 characters long'); + }); + + test('should return same password if valid', () => { + const validPassword = 'abcd1234X'; + + const validated = passwordSchema.parse(validPassword); + + expect(validated).toBe(validPassword); + }); + + test('should require at least one uppercase letter', () => { + const invalidPassword = 'abcd1234'; + + const failingCheck = () => passwordSchema.parse(invalidPassword); + + expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.'); + }); + + test('should require at least one number', () => { + const validPassword = 'abcd1234X'; + const invalidPassword = 'abcdEFGH'; + + const validated = passwordSchema.parse(validPassword); + + expect(validated).toBe(validPassword); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must contain at least 1 number.'); + }); + + test('should require a minimum length of 8 characters', () => { + const invalidPassword = 'a'.repeat(7); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); + + test('should require a maximum length of 64 characters', () => { + const invalidPassword = 'a'.repeat(65); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/bannerName.schema.ts b/packages/@n8n/api-types/src/schemas/bannerName.schema.ts new file mode 100644 index 0000000000000..445bc31d1ad52 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/bannerName.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const bannerNameSchema = z.enum([ + 'V1', + 'TRIAL_OVER', + 'TRIAL', + 'NON_PRODUCTION_LICENSE', + 'EMAIL_CONFIRMATION', +]); + +export type BannerName = z.infer; diff --git a/packages/@n8n/api-types/src/schemas/booleanFromString.ts b/packages/@n8n/api-types/src/schemas/booleanFromString.ts new file mode 100644 index 0000000000000..bcc9e8133c853 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/booleanFromString.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const booleanFromString = z.enum(['true', 'false']).transform((value) => value === 'true'); diff --git a/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts new file mode 100644 index 0000000000000..3edb8cc5fe6df --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const nodeVersionSchema = z + .number() + .min(1) + .refine( + (val) => { + const parts = String(val).split('.'); + return ( + (parts.length === 1 && !isNaN(Number(parts[0]))) || + (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1]))) + ); + }, + { + message: 'Invalid node version. Must be in format: major.minor', + }, + ); diff --git a/packages/@n8n/api-types/src/schemas/password.schema.ts b/packages/@n8n/api-types/src/schemas/password.schema.ts new file mode 100644 index 0000000000000..3c60470af730d --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/password.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +// TODO: Delete these from `cli` after all password-validation code starts using this schema +const minLength = 8; +const maxLength = 64; + +export const passwordSchema = z + .string() + .min(minLength, `Password must be ${minLength} to ${maxLength} characters long.`) + .max(maxLength, `Password must be ${minLength} to ${maxLength} characters long.`) + .refine((password) => /\d/.test(password), { + message: 'Password must contain at least 1 number.', + }) + .refine((password) => /[A-Z]/.test(password), { + message: 'Password must contain at least 1 uppercase letter.', + }); diff --git a/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts b/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts new file mode 100644 index 0000000000000..b7c55bb8869fb --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const passwordResetTokenSchema = z.string().min(10, 'Token too short'); diff --git a/packages/@n8n/config/src/configs/aiAssistant.config.ts b/packages/@n8n/config/src/configs/aiAssistant.config.ts new file mode 100644 index 0000000000000..ff8a3986f2c4f --- /dev/null +++ b/packages/@n8n/config/src/configs/aiAssistant.config.ts @@ -0,0 +1,8 @@ +import { Config, Env } from '../decorators'; + +@Config +export class AiAssistantConfig { + /** Base URL of the AI assistant service */ + @Env('N8N_AI_ASSISTANT_BASE_URL') + baseUrl: string = ''; +} diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index ef4661c1159a2..733278c3e35dd 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -9,6 +9,7 @@ export const LOG_SCOPES = [ 'multi-main-setup', 'pruning', 'pubsub', + 'push', 'redis', 'scaling', 'waiting-executions', @@ -70,10 +71,13 @@ export class LoggingConfig { * - `external-secrets` * - `license` * - `multi-main-setup` + * - `pruning` * - `pubsub` + * - `push` * - `redis` * - `scaling` * - `waiting-executions` + * - `task-runner` * * @example * `N8N_LOG_SCOPES=license` diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index a5144d419602f..945b5f123786d 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -1,3 +1,4 @@ +import { AiAssistantConfig } from './configs/aiAssistant.config'; import { CacheConfig } from './configs/cache.config'; import { CredentialsConfig } from './configs/credentials.config'; import { DatabaseConfig } from './configs/database.config'; @@ -121,4 +122,7 @@ export class GlobalConfig { @Nested diagnostics: DiagnosticsConfig; + + @Nested + aiAssistant: AiAssistantConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 771d915ee4970..d6d19c47fe2fc 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -289,6 +289,9 @@ describe('GlobalConfig', () => { apiHost: 'https://ph.n8n.io', }, }, + aiAssistant: { + baseUrl: '', + }, }; it('should use all default values when no env variables are defined', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts index 36a5773245e4b..619869bf9d88e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts @@ -18,14 +18,22 @@ export async function apiRequest( endpoint: string, parameters?: RequestParameters, ) { - const { body, qs, uri, option, headers } = parameters ?? {}; + const { body, qs, option, headers } = parameters ?? {}; + + const credentials = await this.getCredentials('openAiApi'); + + let uri = `https://api.openai.com/v1${endpoint}`; + + if (credentials.url) { + uri = `${credentials?.url}${endpoint}`; + } const options = { headers, method, body, qs, - uri: uri ?? `https://api.openai.com/v1${endpoint}`, + uri, json: true, }; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts new file mode 100644 index 0000000000000..399dad85629a0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts @@ -0,0 +1,62 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { apiRequest } from '../index'; + +const mockedExecutionContext = { + getCredentials: jest.fn(), + helpers: { + requestWithAuthentication: jest.fn(), + }, +}; + +describe('apiRequest', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should call requestWithAuthentication with credentials URL if one is provided', async () => { + mockedExecutionContext.getCredentials.mockResolvedValue({ + url: 'http://www.test/url/v1', + }); + + // Act + await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', { + headers: { 'Content-Type': 'application/json' }, + }); + + // Assert + + expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi'); + expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'openAiApi', + { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + uri: 'http://www.test/url/v1/test', + json: true, + }, + ); + }); + + it('should call requestWithAuthentication with default URL if credentials URL is not provided', async () => { + mockedExecutionContext.getCredentials.mockResolvedValue({}); + + // Act + await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', { + headers: { 'Content-Type': 'application/json' }, + }); + + // Assert + + expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi'); + expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'openAiApi', + { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + uri: 'https://api.openai.com/v1/test', + json: true, + }, + ); + }); +}); diff --git a/packages/@n8n/permissions/src/combineScopes.ts b/packages/@n8n/permissions/src/combineScopes.ee.ts similarity index 98% rename from packages/@n8n/permissions/src/combineScopes.ts rename to packages/@n8n/permissions/src/combineScopes.ee.ts index 23da64d8379bc..96a43b940cd89 100644 --- a/packages/@n8n/permissions/src/combineScopes.ts +++ b/packages/@n8n/permissions/src/combineScopes.ee.ts @@ -1,4 +1,4 @@ -import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types'; +import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types.ee'; export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set; export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set; diff --git a/packages/@n8n/permissions/src/constants.ts b/packages/@n8n/permissions/src/constants.ee.ts similarity index 100% rename from packages/@n8n/permissions/src/constants.ts rename to packages/@n8n/permissions/src/constants.ee.ts diff --git a/packages/@n8n/permissions/src/hasScope.ts b/packages/@n8n/permissions/src/hasScope.ee.ts similarity index 90% rename from packages/@n8n/permissions/src/hasScope.ts rename to packages/@n8n/permissions/src/hasScope.ee.ts index d449283490991..81bcbc5175d86 100644 --- a/packages/@n8n/permissions/src/hasScope.ts +++ b/packages/@n8n/permissions/src/hasScope.ee.ts @@ -1,5 +1,5 @@ -import { combineScopes } from './combineScopes'; -import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types'; +import { combineScopes } from './combineScopes.ee'; +import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types.ee'; export function hasScope( scope: Scope | Scope[], diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index f04f2e4ef68ac..ae20358303a6b 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,4 +1,4 @@ -export type * from './types'; -export * from './constants'; -export * from './hasScope'; -export * from './combineScopes'; +export type * from './types.ee'; +export * from './constants.ee'; +export * from './hasScope.ee'; +export * from './combineScopes.ee'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ee.ts similarity index 96% rename from packages/@n8n/permissions/src/types.ts rename to packages/@n8n/permissions/src/types.ee.ts index b36fb792ae435..db74668fbe9fc 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -1,4 +1,4 @@ -import type { RESOURCES } from './constants'; +import type { RESOURCES } from './constants.ee'; export type Resource = keyof typeof RESOURCES; diff --git a/packages/@n8n/permissions/test/hasScope.test.ts b/packages/@n8n/permissions/test/hasScope.test.ts index 0e43bc8dc639e..b3362e4ea64f5 100644 --- a/packages/@n8n/permissions/test/hasScope.test.ts +++ b/packages/@n8n/permissions/test/hasScope.test.ts @@ -1,5 +1,5 @@ -import { hasScope } from '@/hasScope'; -import type { Scope } from '@/types'; +import { hasScope } from '@/hasScope.ee'; +import type { Scope } from '@/types.ee'; const ownerPermissions: Scope[] = [ 'workflow:create', diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index dbb94038946fd..5ebd965e874f4 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -1,4 +1,3 @@ -import { mock } from 'jest-mock-extended'; import { DateTime } from 'luxon'; import type { IBinaryData } from 'n8n-workflow'; import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; @@ -18,11 +17,12 @@ import { type DataRequestResponse, type InputDataChunkDefinition, } from '@/runner-types'; -import type { Task } from '@/task-runner'; +import type { TaskParams } from '@/task-runner'; import { newDataRequestResponse, - newTaskWithSettings, + newTaskParamsWithSettings, + newTaskState, withPairedItem, wrapIntoJson, } from './test-data'; @@ -64,12 +64,12 @@ describe('JsTaskRunner', () => { taskData, runner = defaultTaskRunner, }: { - task: Task; + task: TaskParams; taskData: DataRequestResponse; runner?: JsTaskRunner; }) => { jest.spyOn(runner, 'requestData').mockResolvedValue(taskData); - return await runner.executeTask(task, mock()); + return await runner.executeTask(task, new AbortController().signal); }; afterEach(() => { @@ -88,7 +88,7 @@ describe('JsTaskRunner', () => { runner?: JsTaskRunner; }) => { return await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code, nodeMode: 'runOnceForAllItems', ...settings, @@ -112,7 +112,7 @@ describe('JsTaskRunner', () => { chunk?: InputDataChunkDefinition; }) => { return await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code, nodeMode: 'runOnceForEachItem', chunk, @@ -128,7 +128,7 @@ describe('JsTaskRunner', () => { 'should make an rpc call for console log in %s mode', async (nodeMode) => { jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); - const task = newTaskWithSettings({ + const task = newTaskParamsWithSettings({ code: "console.log('Hello', 'world!'); return {}", nodeMode, }); @@ -139,13 +139,14 @@ describe('JsTaskRunner', () => { }); expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [ - 'Hello world!', + "'Hello'", + "'world!'", ]); }, ); it('should not throw when using unsupported console methods', async () => { - const task = newTaskWithSettings({ + const task = newTaskParamsWithSettings({ code: ` console.warn('test'); console.error('test'); @@ -173,6 +174,44 @@ describe('JsTaskRunner', () => { }), ).resolves.toBeDefined(); }); + + it('should not throw when trying to log the context object', async () => { + const task = newTaskParamsWithSettings({ + code: ` + console.log(this); + return {json: {}} + `, + nodeMode: 'runOnceForAllItems', + }); + + await expect( + execTaskWithParams({ + task, + taskData: newDataRequestResponse([wrapIntoJson({})]), + }), + ).resolves.toBeDefined(); + }); + + it('should log the context object as [[ExecutionContext]]', async () => { + const rpcCallSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); + + const task = newTaskParamsWithSettings({ + code: ` + console.log(this); + return {json: {}} + `, + nodeMode: 'runOnceForAllItems', + }); + + await execTaskWithParams({ + task, + taskData: newDataRequestResponse([wrapIntoJson({})]), + }); + + expect(rpcCallSpy).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [ + '[[ExecutionContext]]', + ]); + }); }); describe('built-in methods and variables available in the context', () => { @@ -297,7 +336,7 @@ describe('JsTaskRunner', () => { describe('$env', () => { it('should have the env available in context when access has not been blocked', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.VAR1 }', nodeMode: 'runOnceForAllItems', }), @@ -316,7 +355,7 @@ describe('JsTaskRunner', () => { it('should be possible to access env if it has been blocked', async () => { await expect( execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.VAR1 }', nodeMode: 'runOnceForAllItems', }), @@ -333,7 +372,7 @@ describe('JsTaskRunner', () => { it('should not be possible to iterate $env', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return Object.values($env).concat(Object.keys($env))', nodeMode: 'runOnceForAllItems', }), @@ -352,7 +391,7 @@ describe('JsTaskRunner', () => { 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'; const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }', nodeMode: 'runOnceForAllItems', }), @@ -373,7 +412,7 @@ describe('JsTaskRunner', () => { }; const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $now.toSeconds() }', nodeMode: 'runOnceForAllItems', }), @@ -390,7 +429,7 @@ describe('JsTaskRunner', () => { }); const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $now.toSeconds() }', nodeMode: 'runOnceForAllItems', }), @@ -405,7 +444,7 @@ describe('JsTaskRunner', () => { describe("$getWorkflowStaticData('global')", () => { it('should have the global workflow static data available in runOnceForAllItems', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("global") }', nodeMode: 'runOnceForAllItems', }), @@ -421,7 +460,7 @@ describe('JsTaskRunner', () => { it('should have the global workflow static data available in runOnceForEachItem', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("global") }', nodeMode: 'runOnceForEachItem', }), @@ -441,7 +480,7 @@ describe('JsTaskRunner', () => { "does not return static data if it hasn't been modified in %s", async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("global"); return { val: staticData }; @@ -463,7 +502,7 @@ describe('JsTaskRunner', () => { 'returns the updated static data in %s', async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("global"); staticData.newKey = 'newValue'; @@ -502,7 +541,7 @@ describe('JsTaskRunner', () => { it('should have the node workflow static data available in runOnceForAllItems', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("node") }', nodeMode: 'runOnceForAllItems', }), @@ -514,7 +553,7 @@ describe('JsTaskRunner', () => { it('should have the node workflow static data available in runOnceForEachItem', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("node") }', nodeMode: 'runOnceForEachItem', }), @@ -530,7 +569,7 @@ describe('JsTaskRunner', () => { "does not return static data if it hasn't been modified in %s", async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("node"); return { val: staticData }; @@ -548,7 +587,7 @@ describe('JsTaskRunner', () => { 'returns the updated static data in %s', async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("node"); staticData.newKey = 'newValue'; @@ -623,7 +662,7 @@ describe('JsTaskRunner', () => { // Act await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: `await ${group.invocation}; return []`, nodeMode: 'runOnceForAllItems', }), @@ -633,6 +672,7 @@ describe('JsTaskRunner', () => { ), }); + // Assert expect(rpcCallSpy).toHaveBeenCalledWith('1', group.method, group.expectedParams); }); @@ -644,7 +684,7 @@ describe('JsTaskRunner', () => { // Act await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: `await ${group.invocation}; return {}`, nodeMode: 'runOnceForEachItem', }), @@ -661,26 +701,22 @@ describe('JsTaskRunner', () => { describe('unsupported methods', () => { for (const unsupportedFunction of UNSUPPORTED_HELPER_FUNCTIONS) { it(`should throw an error if ${unsupportedFunction} is used in runOnceForAllItems`, async () => { - // Act - + // Act & Assert await expect( - async () => - await executeForAllItems({ - code: `${unsupportedFunction}()`, - inputItems, - }), + executeForAllItems({ + code: `${unsupportedFunction}()`, + inputItems, + }), ).rejects.toThrow(UnsupportedFunctionError); }); it(`should throw an error if ${unsupportedFunction} is used in runOnceForEachItem`, async () => { - // Act - + // Act & Assert await expect( - async () => - await executeForEachItem({ - code: `${unsupportedFunction}()`, - inputItems, - }), + executeForEachItem({ + code: `${unsupportedFunction}()`, + inputItems, + }), ).rejects.toThrow(UnsupportedFunctionError); }); } @@ -689,7 +725,7 @@ describe('JsTaskRunner', () => { it('should allow access to Node.js Buffers', async () => { const outcomeAll = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: Buffer.from("test-buffer").toString() }', nodeMode: 'runOnceForAllItems', }), @@ -701,7 +737,7 @@ describe('JsTaskRunner', () => { expect(outcomeAll.result).toEqual([wrapIntoJson({ val: 'test-buffer' })]); const outcomePer = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: Buffer.from("test-buffer").toString() }', nodeMode: 'runOnceForEachItem', }), @@ -1169,7 +1205,7 @@ describe('JsTaskRunner', () => { async (nodeMode) => { await expect( execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'unknown', nodeMode, }), @@ -1182,12 +1218,13 @@ describe('JsTaskRunner', () => { it('sends serializes an error correctly', async () => { const runner = createRunnerWithOpts({}); const taskId = '1'; - const task = newTaskWithSettings({ + const task = newTaskState(taskId); + const taskSettings: JSExecSettings = { code: 'unknown; return []', nodeMode: 'runOnceForAllItems', continueOnFail: false, workflowMode: 'manual', - }); + }; runner.runningTasks.set(taskId, task); const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {}); @@ -1196,7 +1233,7 @@ describe('JsTaskRunner', () => { .spyOn(runner, 'requestData') .mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })])); - await runner.receivedSettings(taskId, task.settings); + await runner.receivedSettings(taskId, taskSettings); expect(sendSpy).toHaveBeenCalled(); const calledWith = sendSpy.mock.calls[0][0] as string; @@ -1268,11 +1305,7 @@ describe('JsTaskRunner', () => { const emitSpy = jest.spyOn(runner, 'emit'); jest.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] }); - runner.runningTasks.set(taskId, { - taskId, - active: true, - cancelled: false, - }); + runner.runningTasks.set(taskId, newTaskState(taskId)); jest.advanceTimersByTime(idleTimeout * 1000 - 100); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); @@ -1299,15 +1332,13 @@ describe('JsTaskRunner', () => { const runner = createRunnerWithOpts({}, { idleTimeout }); const taskId = '123'; const emitSpy = jest.spyOn(runner, 'emit'); + const task = newTaskState(taskId); - runner.runningTasks.set(taskId, { - taskId, - active: true, - cancelled: false, - }); + runner.runningTasks.set(taskId, task); jest.advanceTimersByTime(idleTimeout * 1000); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); + task.cleanup(); }); }); }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts index e12770f7705b3..9db385eb7f97b 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -1,32 +1,47 @@ import { WebSocket } from 'ws'; -import { TaskRunner } from '@/task-runner'; +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 type { TaskStatus } from '@/task-state'; class TestRunner extends TaskRunner {} jest.mock('ws'); describe('TestRunner', () => { + let runner: TestRunner; + + const newTestRunner = (opts: Partial = {}) => + new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'http://localhost:8080', + timezone: 'America/New_York', + taskTimeout: 60, + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + ...opts, + }); + + afterEach(() => { + runner?.clearIdleTimer(); + }); + describe('constructor', () => { afterEach(() => { jest.clearAllMocks(); }); it('should correctly construct WebSocket URI with provided taskBrokerUri', () => { - const runner = new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, + runner = newTestRunner({ taskBrokerUri: 'http://localhost:8080', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, }); expect(WebSocket).toHaveBeenCalledWith( @@ -38,25 +53,11 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); - - runner.clearIdleTimer(); }); it('should handle different taskBrokerUri formats correctly', () => { - const runner = new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, + runner = newTestRunner({ taskBrokerUri: 'https://example.com:3000/path', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, }); expect(WebSocket).toHaveBeenCalledWith( @@ -68,56 +69,239 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); - - runner.clearIdleTimer(); }); it('should throw an error if taskBrokerUri is invalid', () => { - expect( - () => - new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, - taskBrokerUri: 'not-a-valid-uri', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, - }), + expect(() => + newTestRunner({ + taskBrokerUri: 'not-a-valid-uri', + }), ).toThrowError(/Invalid URL/); }); }); - describe('taskCancelled', () => { - it('should reject pending requests when task is cancelled', () => { - const runner = new TestRunner({ + describe('sendOffers', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should not send offers if canSendOffers is false', () => { + runner = newTestRunner({ taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, - taskBrokerUri: 'http://localhost:8080', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, + maxConcurrency: 2, + }); + const sendSpy = jest.spyOn(runner, 'send'); + expect(runner.canSendOffers).toBe(false); + + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(0); + }); + + it('should enable sending of offer on runnerregistered message', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + + expect(runner.canSendOffers).toBe(true); + }); + + it('should send maxConcurrency offers when there are no offers', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy.mock.calls).toEqual([ + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + ]); + }); + + it('should send up to maxConcurrency offers when there is a running task', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + const taskState = newTaskState('test-task'); + runner.runningTasks.set('test-task', taskState); + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls).toEqual([ + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + ]); + taskState.cleanup(); + }); + + it('should delete stale offers and send new ones', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + expect(sendSpy).toHaveBeenCalledTimes(2); + sendSpy.mockClear(); + + jest.advanceTimersByTime(6000); + runner.sendOffers(); + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('taskCancelled', () => { + test.each<[TaskStatus, string]>([ + ['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 task = newTaskState(taskId); + task.status = status; + + 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 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.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'; - runner.runningTasks.set(taskId, { + 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, - active: false, - cancelled: false, + 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(); @@ -136,7 +320,7 @@ describe('TestRunner', () => { reject: nodeTypesRequestReject, }); - runner.taskCancelled(taskId, 'test-reason'); + await runner.taskCancelled(taskId, 'test-reason'); expect(dataRequestReject).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts index f13939e51e81e..85a1235dc6600 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts @@ -4,22 +4,21 @@ import { nanoid } from 'nanoid'; import type { JSExecSettings } from '@/js-task-runner/js-task-runner'; 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 */ -export const newTaskWithSettings = ( +export const newTaskParamsWithSettings = ( settings: Partial & Pick, -): Task => ({ +): TaskParams => ({ taskId: '1', settings: { workflowMode: 'manual', continueOnFail: false, ...settings, }, - active: true, - cancelled: false, }); /** @@ -167,3 +166,13 @@ export const withPairedItem = (index: number, data: INodeExecutionData): INodeEx item: index, }, }); + +/** + * Creates a new task state with the given taskId + */ +export const newTaskState = (taskId: string) => + new TaskState({ + taskId, + timeoutInS: 60, + onTimeout: () => {}, + }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 04e05fb30aed6..ab2cc3a30428e 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -15,21 +15,23 @@ import type { EnvProviderState, IExecuteData, INodeTypeDescription, + IWorkflowDataProxyData, } from 'n8n-workflow'; import * as a from 'node:assert'; +import { inspect } from 'node:util'; import { runInNewContext, type Context } from 'node:vm'; import type { MainConfig } from '@/config/main-config'; import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error'; -import { - EXPOSED_RPC_METHODS, - UNSUPPORTED_HELPER_FUNCTIONS, - type DataRequestResponse, - type InputDataChunkDefinition, - type PartialAdditionalData, - type TaskResultData, +import { EXPOSED_RPC_METHODS, UNSUPPORTED_HELPER_FUNCTIONS } from '@/runner-types'; +import type { + DataRequestResponse, + InputDataChunkDefinition, + PartialAdditionalData, + TaskResultData, } 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 { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state'; @@ -42,8 +44,8 @@ import { createRequireResolver } from './require-resolver'; import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation'; import { DataRequestResponseReconstruct } from '../data-request/data-request-response-reconstruct'; -export interface RPCCallObject { - [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; +export interface RpcCallObject { + [name: string]: ((...args: unknown[]) => Promise) | RpcCallObject; } export interface JSExecSettings { @@ -103,8 +105,11 @@ export class JsTaskRunner extends TaskRunner { }); } - async executeTask(task: Task, signal: AbortSignal): Promise { - const settings = task.settings; + async executeTask( + taskParams: TaskParams, + abortSignal: AbortSignal, + ): Promise { + const { taskId, settings } = taskParams; a.ok(settings, 'JS Code not sent to runner'); this.validateTaskSettings(settings); @@ -115,13 +120,13 @@ export class JsTaskRunner extends TaskRunner { : BuiltInsParserState.newNeedsAllDataState(); const dataResponse = await this.requestData( - task.taskId, + taskId, neededBuiltIns.toDataRequestParams(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 workflow = new Workflow({ @@ -129,29 +134,12 @@ export class JsTaskRunner extends TaskRunner { nodeTypes: this.nodeTypes, }); - const noOp = () => {}; - const customConsole = { - // all except `log` are dummy methods that disregard without throwing, following existing Code node behavior - ...Object.keys(console).reduce void>>((acc, name) => { - acc[name] = noOp; - return acc; - }, {}), - // Send log output back to the main process. It will take care of forwarding - // it to the UI or printing to console. - log: (...args: unknown[]) => { - const logOutput = args - .map((arg) => (typeof arg === 'object' && arg !== null ? JSON.stringify(arg) : arg)) - .join(' '); - void this.makeRpcCall(task.taskId, 'logNodeOutput', [logOutput]); - }, - }; - workflow.staticData = ObservableObject.create(workflow.staticData); const result = settings.nodeMode === 'runOnceForAllItems' - ? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole, signal) - : await this.runForEachItem(task.taskId, settings, data, workflow, customConsole, signal); + ? await this.runForAllItems(taskId, settings, data, workflow, abortSignal) + : await this.runForEachItem(taskId, settings, data, workflow, abortSignal); return { result, @@ -200,22 +188,14 @@ export class JsTaskRunner extends TaskRunner { settings: JSExecSettings, data: JsTaskData, workflow: Workflow, - customConsole: CustomConsole, signal: AbortSignal, ): Promise { const dataProxy = this.createDataProxy(data, workflow, data.itemIndex); const inputItems = data.connectionInputData; - const context: Context = { - require: this.requireResolver, - module: {}, - console: customConsole, + const context = this.buildContext(taskId, workflow, data.node, dataProxy, { items: inputItems, - $getWorkflowStaticData: (type: 'global' | 'node') => workflow.getStaticData(type, data.node), - ...this.getNativeVariables(), - ...dataProxy, - ...this.buildRpcCallObject(taskId), - }; + }); try { const result = await new Promise((resolve, reject) => { @@ -264,7 +244,6 @@ export class JsTaskRunner extends TaskRunner { settings: JSExecSettings, data: JsTaskData, workflow: Workflow, - customConsole: CustomConsole, signal: AbortSignal, ): Promise { const inputItems = data.connectionInputData; @@ -279,17 +258,7 @@ export class JsTaskRunner extends TaskRunner { for (let index = chunkStartIdx; index < chunkEndIdx; index++) { const item = inputItems[index]; const dataProxy = this.createDataProxy(data, workflow, index); - const context: Context = { - require: this.requireResolver, - module: {}, - console: customConsole, - item, - $getWorkflowStaticData: (type: 'global' | 'node') => - workflow.getStaticData(type, data.node), - ...this.getNativeVariables(), - ...dataProxy, - ...this.buildRpcCallObject(taskId), - }; + const context = this.buildContext(taskId, workflow, data.node, dataProxy, { item }); try { let result = await new Promise((resolve, reject) => { @@ -449,7 +418,7 @@ export class JsTaskRunner extends TaskRunner { } private buildRpcCallObject(taskId: string) { - const rpcObject: RPCCallObject = {}; + const rpcObject: RpcCallObject = {}; for (const rpcMethod of EXPOSED_RPC_METHODS) { set( @@ -467,4 +436,52 @@ export class JsTaskRunner extends TaskRunner { return rpcObject; } + + private buildCustomConsole(taskId: string): CustomConsole { + return { + // all except `log` are dummy methods that disregard without throwing, following existing Code node behavior + ...Object.keys(console).reduce void>>((acc, name) => { + acc[name] = noOp; + return acc; + }, {}), + + // Send log output back to the main process. It will take care of forwarding + // it to the UI or printing to console. + log: (...args: unknown[]) => { + const formattedLogArgs = args.map((arg) => inspect(arg)); + void this.makeRpcCall(taskId, 'logNodeOutput', formattedLogArgs); + }, + }; + } + + /** + * Builds the 'global' context object that is passed to the script + * + * @param taskId The ID of the task. Needed for RPC calls + * @param workflow The workflow that is being executed. Needed for static data + * @param node The node that is being executed. Needed for static data + * @param dataProxy The data proxy object that provides access to built-ins + * @param additionalProperties Additional properties to add to the context + */ + private buildContext( + taskId: string, + workflow: Workflow, + node: INode, + dataProxy: IWorkflowDataProxyData, + additionalProperties: Record = {}, + ): Context { + const context: Context = { + [inspect.custom]: () => '[[ExecutionContext]]', + require: this.requireResolver, + module: {}, + console: this.buildCustomConsole(taskId), + $getWorkflowStaticData: (type: 'global' | 'node') => workflow.getStaticData(type, node), + ...this.getNativeVariables(), + ...dataProxy, + ...this.buildRpcCallObject(taskId), + ...additionalProperties, + }; + + return context; + } } diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index 391b6ba156c66..93ff7422501b3 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -56,7 +56,7 @@ void (async function start() { if (config.sentryConfig.sentryDsn) { const { ErrorReporter } = await import('n8n-core'); - errorReporter = new ErrorReporter(); + errorReporter = Container.get(ErrorReporter); await errorReporter.init('task_runner', config.sentryConfig.sentryDsn); } diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index 4254aad99c29f..dd048bcf7ebed 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -5,19 +5,14 @@ import { EventEmitter } from 'node:events'; import { type MessageEvent, WebSocket } from 'ws'; 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 { TaskRunnerNodeTypes } from '@/node-types'; import type { TaskResultData } from '@/runner-types'; +import { TaskState } from '@/task-state'; import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error'; -export interface Task { - taskId: string; - settings?: T; - active: boolean; - cancelled: boolean; -} - export interface TaskOffer { offerId: string; validUntil: bigint; @@ -49,6 +44,14 @@ const OFFER_VALID_EXTRA_MS = 100; /** Converts milliseconds to nanoseconds */ const msToNs = (ms: number) => BigInt(ms * 1_000_000); +export const noOp = () => {}; + +/** Params the task receives when it is executed */ +export interface TaskParams { + taskId: string; + settings: T; +} + export interface TaskRunnerOpts extends BaseRunnerConfig { taskType: string; name?: string; @@ -61,7 +64,7 @@ export abstract class TaskRunner extends EventEmitter { canSendOffers = false; - runningTasks: Map = new Map(); + runningTasks: Map = new Map(); 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. */ private readonly idleTimeout: number; - protected taskCancellations = new Map(); - constructor(opts: TaskRunnerOpts) { super(); + this.taskType = opts.taskType; this.name = opts.name ?? 'Node.js Task Runner SDK'; this.maxConcurrency = opts.maxConcurrency; @@ -174,9 +176,11 @@ export abstract class TaskRunner extends EventEmitter { sendOffers() { this.deleteStaleOffers(); - const offersToSend = - this.maxConcurrency - - (Object.values(this.openOffers).length + Object.values(this.runningTasks).length); + if (!this.canSendOffers) { + return; + } + + const offersToSend = this.maxConcurrency - (this.openOffers.size + this.runningTasks.size); for (let i = 0; i < offersToSend; i++) { // Add a bit of randomness so that not all offers expire at the same time @@ -217,7 +221,7 @@ export abstract class TaskRunner extends EventEmitter { this.offerAccepted(message.offerId, message.taskId); break; case 'broker:taskcancel': - this.taskCancelled(message.taskId, message.reason); + void this.taskCancelled(message.taskId, message.reason); break; case 'broker:tasksettings': void this.receivedSettings(message.taskId, message.settings); @@ -255,11 +259,12 @@ export abstract class TaskRunner extends EventEmitter { } hasOpenTasks() { - return Object.values(this.runningTasks).length < this.maxConcurrency; + return this.runningTasks.size < this.maxConcurrency; } offerAccepted(offerId: string, taskId: string) { if (!this.hasOpenTasks()) { + this.openOffers.delete(offerId); this.send({ type: 'runner:taskrejected', taskId, @@ -267,6 +272,7 @@ export abstract class TaskRunner extends EventEmitter { }); return; } + const offer = this.openOffers.get(offerId); if (!offer) { this.send({ @@ -280,11 +286,14 @@ export abstract class TaskRunner extends EventEmitter { } this.resetIdleTimer(); - this.runningTasks.set(taskId, { + const taskState = new TaskState({ taskId, - active: false, - cancelled: false, + timeoutInS: this.taskTimeout, + onTimeout: () => { + void this.taskTimedOut(taskId); + }, }); + this.runningTasks.set(taskId, taskState); this.send({ type: 'runner:taskaccepted', @@ -292,99 +301,103 @@ export abstract class TaskRunner extends EventEmitter { }); } - taskCancelled(taskId: string, reason: string) { - const task = this.runningTasks.get(taskId); - if (!task) { + async taskCancelled(taskId: string, reason: string) { + const taskState = this.runningTasks.get(taskId); + if (!taskState) { return; } - task.cancelled = true; - for (const [requestId, request] of this.dataRequests.entries()) { - if (request.taskId === taskId) { - request.reject(new TaskCancelledError(reason)); - this.dataRequests.delete(requestId); - } - } + await taskState.caseOf({ + // If the cancelled task hasn't received settings yet, we can finish it + waitingForSettings: () => this.finishTask(taskState), - for (const [requestId, request] of this.nodeTypesRequests.entries()) { - if (request.taskId === taskId) { - request.reject(new TaskCancelledError(reason)); - this.nodeTypesRequests.delete(requestId); - } - } + // If the task has already timed out or is already cancelled, we can + // ignore the cancellation + 'aborting:timeout': noOp, + 'aborting:cancelled': noOp, - const controller = this.taskCancellations.get(taskId); - if (controller) { - controller.abort(); - this.taskCancellations.delete(taskId); + running: () => { + taskState.status = 'aborting:cancelled'; + taskState.abortController.abort('cancelled'); + this.cancelTaskRequests(taskId, reason); + }, + }); + } + + async taskTimedOut(taskId: string) { + const taskState = this.runningTasks.get(taskId); + if (!taskState) { + return; } - if (!task.active) this.runningTasks.delete(taskId); + await taskState.caseOf({ + // If we are still waiting for settings for the task, we can error the + // task immediately + waitingForSettings: () => { + try { + this.send({ + type: 'runner:taskerror', + taskId, + error: new TimeoutError(this.taskTimeout), + }); + } finally { + this.finishTask(taskState); + } + }, - this.sendOffers(); - } + // This should never happen, the timeout timer should only fire once + 'aborting:timeout': TaskState.throwUnexpectedTaskStatus, - taskErrored(taskId: string, error: unknown) { - this.send({ - type: 'runner:taskerror', - taskId, - error, - }); - this.runningTasks.delete(taskId); - this.sendOffers(); - } + // 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'); + }, - taskDone(taskId: string, data: RunnerMessage.ToBroker.TaskDone['data']) { - this.send({ - type: 'runner:taskdone', - taskId, - data, + // 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) { - const task = this.runningTasks.get(taskId); - if (!task) { + const taskState = this.runningTasks.get(taskId); + if (!taskState) { return; } - if (task.cancelled) { - this.runningTasks.delete(taskId); - return; - } - - const controller = new AbortController(); - this.taskCancellations.set(taskId, controller); - const taskTimeout = setTimeout(() => { - if (!task.cancelled) { - controller.abort(); - this.taskCancellations.delete(taskId); - } - }, this.taskTimeout * 1_000); - - task.settings = settings; - task.active = true; - try { - const data = await this.executeTask(task, controller.signal); - this.taskDone(taskId, data); - } catch (error) { - if (!task.cancelled) this.taskErrored(taskId, error); - } finally { - clearTimeout(taskTimeout); - this.taskCancellations.delete(taskId); - this.resetIdleTimer(); - } + await taskState.caseOf({ + // 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, + + waitingForSettings: async () => { + taskState.status = 'running'; + + await this.executeTask( + { + taskId, + settings, + }, + taskState.abortController.signal, + ) + .then(async (data) => await this.taskExecutionSucceeded(taskState, data)) + .catch(async (error) => await this.taskExecutionFailed(taskState, error)); + }, + }); } // eslint-disable-next-line @typescript-eslint/naming-convention - async executeTask(_task: Task, _signal: AbortSignal): Promise { + async executeTask(_taskParams: TaskParams, _signal: AbortSignal): Promise { throw new ApplicationError('Unimplemented'); } async requestNodeTypes( - taskId: Task['taskId'], + taskId: TaskState['taskId'], requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'], ) { const requestId = nanoid(); @@ -413,12 +426,12 @@ export abstract class TaskRunner extends EventEmitter { } async requestData( - taskId: Task['taskId'], + taskId: TaskState['taskId'], requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'], ): Promise { const requestId = nanoid(); - const p = new Promise((resolve, reject) => { + const dataRequestPromise = new Promise((resolve, reject) => { this.dataRequests.set(requestId, { requestId, taskId, @@ -435,7 +448,7 @@ export abstract class TaskRunner extends EventEmitter { }); try { - return await p; + return await dataRequestPromise; } finally { this.dataRequests.delete(requestId); } @@ -452,15 +465,15 @@ export abstract class TaskRunner extends EventEmitter { }); }); - this.send({ - type: 'runner:rpc', - callId, - taskId, - name, - params, - }); - try { + this.send({ + type: 'runner:rpc', + callId, + taskId, + name, + params, + }); + const returnValue = await dataPromise; return isSerializedBuffer(returnValue) ? toBuffer(returnValue) : returnValue; @@ -523,4 +536,86 @@ export abstract class TaskRunner extends EventEmitter { 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(); + } } diff --git a/packages/@n8n/task-runner/src/task-state.ts b/packages/@n8n/task-runner/src/task-state.ts new file mode 100644 index 0000000000000..4c2c0e44a8d59 --- /dev/null +++ b/packages/@n8n/task-runner/src/task-state.ts @@ -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 void | Promise | 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)}`); + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e9ff0f7ca483..3afa78ebade7d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -94,7 +94,7 @@ "@n8n/permissions": "workspace:*", "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "0.3.20-12", - "@n8n_io/ai-assistant-sdk": "1.12.0", + "@n8n_io/ai-assistant-sdk": "1.13.0", "@n8n_io/license-sdk": "2.13.1", "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.0.9", @@ -104,7 +104,6 @@ "bcryptjs": "2.4.3", "bull": "4.12.1", "cache-manager": "5.2.3", - "callsites": "3.1.0", "change-case": "4.1.2", "class-transformer": "0.5.1", "class-validator": "0.14.0", @@ -149,7 +148,7 @@ "p-cancelable": "2.1.1", "p-lazy": "3.1.0", "pg": "8.12.0", - "picocolors": "1.0.1", + "picocolors": "catalog:", "pkce-challenge": "3.0.0", "posthog-node": "3.2.1", "prom-client": "13.2.0", @@ -169,7 +168,6 @@ "typedi": "catalog:", "uuid": "catalog:", "validator": "13.7.0", - "winston": "3.14.2", "ws": "8.17.1", "xml2js": "catalog:", "xmllint-wasm": "3.0.1", diff --git a/packages/cli/src/__tests__/project.test-data.ts b/packages/cli/src/__tests__/project.test-data.ts new file mode 100644 index 0000000000000..3ffac36fc8997 --- /dev/null +++ b/packages/cli/src/__tests__/project.test-data.ts @@ -0,0 +1,19 @@ +import { nanoId, date, firstName, lastName, email } from 'minifaker'; +import 'minifaker/locales/en'; + +import type { Project, ProjectType } from '@/databases/entities/project'; + +type RawProjectData = Pick; + +const projectName = `${firstName()} ${lastName()} <${email}>`; + +export const createRawProjectData = (payload: Partial): Project => { + return { + createdAt: date(), + updatedAt: date(), + id: nanoId.nanoid(), + name: projectName, + type: 'personal' as ProjectType, + ...payload, + } as Project; +}; diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index e7d94d3e34e04..641e2393931e6 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -15,10 +15,10 @@ import { CredentialsHelper } from '@/credentials-helper'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index f4a8a5b2ccd8d..aadd41fb051f5 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -5,6 +5,7 @@ import { engine as expressHandlebars } from 'express-handlebars'; import { readFile } from 'fs/promises'; import type { Server } from 'http'; import isbot from 'isbot'; +import { Logger } from 'n8n-core'; import { Container, Service } from 'typedi'; import config from '@/config'; @@ -12,7 +13,6 @@ import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants'; import * as Db from '@/db'; import { OnShutdown } from '@/decorators/on-shutdown'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; import { send, sendErrorResponse } from '@/response-helper'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; diff --git a/packages/cli/src/active-executions.ts b/packages/cli/src/active-executions.ts index bc18eade16f9c..a3fdcf6fee644 100644 --- a/packages/cli/src/active-executions.ts +++ b/packages/cli/src/active-executions.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import type { IDeferredPromise, IExecuteResponsePromiseData, @@ -18,7 +19,6 @@ import type { IExecutionDb, IExecutionsCurrentSummary, } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowIdValid } from '@/utils'; import { ConcurrencyControlService } from './concurrency/concurrency-control.service'; diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 6ef3753af74be..368e2987c80aa 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -3,6 +3,7 @@ import { ActiveWorkflows, ErrorReporter, InstanceSettings, + Logger, PollContext, TriggerContext, } from 'n8n-core'; @@ -42,7 +43,6 @@ import { OnShutdown } from '@/decorators/on-shutdown'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; import type { IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 492e22ab53331..3a2e4fb0cb127 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import { createHash } from 'crypto'; import type { NextFunction, Response } from 'express'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; +import { Logger } from 'n8n-core'; import Container, { Service } from 'typedi'; import config from '@/config'; @@ -12,7 +13,6 @@ import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import type { AuthenticatedRequest } from '@/requests'; import { JwtService } from '@/services/jwt.service'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index 723365057f66c..6f378e4357a89 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -4,7 +4,7 @@ import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { EventService } from '@/events/event.service'; -import { isLdapLoginEnabled } from '@/ldap/helpers.ee'; +import { isLdapLoginEnabled } from '@/ldap.ee/helpers.ee'; import { PasswordUtility } from '@/services/password.utility'; export const handleEmailLogin = async ( diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index b8fea259891f8..f067994215424 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -10,8 +10,8 @@ import { mapLdapAttributesToUser, createLdapAuthIdentity, updateLdapUserOnLocalDb, -} from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; +} from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; export const handleLdapLogin = async ( loginId: string, diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 286fec1de6bec..7692ef79bed40 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -4,6 +4,7 @@ import { Command, Errors } from '@oclif/core'; import { BinaryDataService, InstanceSettings, + Logger, ObjectStoreService, DataDeduplicationService, ErrorReporter, @@ -22,14 +23,13 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { initExpressionEvaluator } from '@/expression-evaluator'; import { ExternalHooks } from '@/external-hooks'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; export abstract class BaseCommand extends Command { protected logger = Container.get(Logger); diff --git a/packages/cli/src/commands/db/__tests__/revert.test.ts b/packages/cli/src/commands/db/__tests__/revert.test.ts index ce3911b2b617e..8fdabafbec462 100644 --- a/packages/cli/src/commands/db/__tests__/revert.test.ts +++ b/packages/cli/src/commands/db/__tests__/revert.test.ts @@ -1,10 +1,10 @@ import type { Migration, MigrationExecutor } from '@n8n/typeorm'; import { type DataSource } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import { main } from '@/commands/db/revert'; import type { IrreversibleMigration, ReversibleMigration } from '@/databases/types'; -import { Logger } from '@/logging/logger.service'; import { mockInstance } from '@test/mocking'; const logger = mockInstance(Logger); diff --git a/packages/cli/src/commands/db/revert.ts b/packages/cli/src/commands/db/revert.ts index 45100444059d0..bc9e0f6b3f467 100644 --- a/packages/cli/src/commands/db/revert.ts +++ b/packages/cli/src/commands/db/revert.ts @@ -3,12 +3,12 @@ import type { DataSourceOptions as ConnectionOptions } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { MigrationExecutor, DataSource as Connection } from '@n8n/typeorm'; import { Command, Flags } from '@oclif/core'; +import { Logger } from 'n8n-core'; import { Container } from 'typedi'; import { getConnectionOptions } from '@/databases/config'; import type { Migration } from '@/databases/types'; import { wrapMigration } from '@/databases/utils/migration-helpers'; -import { Logger } from '@/logging/logger.service'; // This function is extracted to make it easier to unit test it. // Mocking turned into a mess due to this command using typeorm and the db diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index f9f1f3d0bb8bf..6aaaa217d60ce 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -14,7 +14,7 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; import { WorkflowService } from '@/workflows/workflow.service'; import { BaseCommand } from '../base-command'; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 63ec3d9240dfa..847f5a480feb5 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -225,7 +225,7 @@ export class Start extends BaseCommand { const { taskRunners: taskRunnerConfig } = this.globalConfig; if (taskRunnerConfig.enabled) { - const { TaskRunnerModule } = await import('@/runners/task-runner-module'); + const { TaskRunnerModule } = await import('@/task-runners/task-runner-module'); const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 64c5a34dae680..c69957dacbd0b 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -7,7 +7,6 @@ import { WorkerMissingEncryptionKey } from '@/errors/worker-missing-encryption-k import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-message-generic'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; -import { Logger } from '@/logging/logger.service'; import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler'; import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import type { ScalingService } from '@/scaling/scaling.service'; @@ -67,7 +66,7 @@ export class Worker extends BaseCommand { super(argv, cmdConfig); - this.logger = Container.get(Logger).scoped('scaling'); + this.logger = this.logger.scoped('scaling'); } async init() { @@ -114,7 +113,7 @@ export class Worker extends BaseCommand { const { taskRunners: taskRunnerConfig } = this.globalConfig; if (taskRunnerConfig.enabled) { - const { TaskRunnerModule } = await import('@/runners/task-runner-module'); + const { TaskRunnerModule } = await import('@/task-runners/task-runner-module'); const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 3896daad2e28f..ede6cf899748b 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -7,7 +8,6 @@ import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit import { UnknownExecutionModeError } from '@/errors/unknown-execution-mode.error'; import { EventService } from '@/events/event.service'; import type { IExecutingWorkflowData } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { Telemetry } from '@/telemetry'; import { ConcurrencyQueue } from './concurrency-queue'; diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 63497600fe300..1ba49a1ef4ce1 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -3,7 +3,9 @@ import convict from 'convict'; import { flatten } from 'flat'; import { readFileSync } from 'fs'; import merge from 'lodash/merge'; +import { Logger } from 'n8n-core'; import { ApplicationError, setGlobalState } from 'n8n-workflow'; +import assert from 'node:assert'; import colors from 'picocolors'; import { Container } from 'typedi'; @@ -31,13 +33,15 @@ const config = convict(schema, { args: [] }); // eslint-disable-next-line @typescript-eslint/unbound-method config.getEnv = config.get; +const logger = Container.get(Logger); +const globalConfig = Container.get(GlobalConfig); + // Load overwrites when not in tests if (!inE2ETests && !inTest) { // Overwrite default configuration with settings which got defined in // optional configuration files const { N8N_CONFIG_FILES } = process.env; if (N8N_CONFIG_FILES !== undefined) { - const globalConfig = Container.get(GlobalConfig); const configFiles = N8N_CONFIG_FILES.split(','); for (const configFile of configFiles) { if (!configFile) continue; @@ -58,9 +62,10 @@ if (!inE2ETests && !inTest) { } } } - console.debug('Loaded config overwrites from', configFile); + logger.debug(`Loaded config overwrites from ${configFile}`); } catch (error) { - console.error('Error loading config file', configFile, error); + assert(error instanceof Error); + logger.error(`Error loading config file ${configFile}`, { error }); } } } @@ -96,7 +101,7 @@ config.validate({ const userManagement = config.get('userManagement'); if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) { if (!inTest) - console.warn( + logger.warn( 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.', ); @@ -105,16 +110,16 @@ if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHo const executionProcess = config.getEnv('executions.process'); if (executionProcess) { - console.error( - colors.yellow('Please unset the deprecated env variable'), - colors.bold(colors.yellow('EXECUTIONS_PROCESS')), + logger.error( + colors.yellow('Please unset the deprecated env variable') + + colors.bold(colors.yellow('EXECUTIONS_PROCESS')), ); } if (executionProcess === 'own') { - console.error( + logger.error( colors.bold(colors.red('Application failed to start because "Own" mode has been removed.')), ); - console.error( + logger.error( colors.red( 'If you need the isolation and performance gains, please consider using queue mode instead.\n\n', ), @@ -123,7 +128,7 @@ if (executionProcess === 'own') { } setGlobalState({ - defaultTimezone: Container.get(GlobalConfig).generic.timezone, + defaultTimezone: globalConfig.generic.timezone, }); // eslint-disable-next-line import/no-default-export diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 54fa07e7f5dcb..e8d28cb782e7c 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -341,15 +341,6 @@ export const schema = { }, }, - aiAssistant: { - baseUrl: { - doc: 'Base URL of the AI assistant service', - format: String, - default: '', - env: 'N8N_AI_ASSISTANT_BASE_URL', - }, - }, - expression: { evaluator: { doc: 'Expression evaluator to use', diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 78d7671e1bf1e..4bd1890c4e21b 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; 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'; const { NODE_ENV, E2E_TESTS } = process.env; @@ -93,6 +94,7 @@ export const LICENSE_FEATURES = { AI_ASSISTANT: 'feat:aiAssistant', ASK_AI: 'feat:askAi', COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry', + AI_CREDITS: 'feat:aiCredits', } as const; export const LICENSE_QUOTAS = { @@ -101,6 +103,7 @@ export const LICENSE_QUOTAS = { USERS_LIMIT: 'quota:users', WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects', + AI_CREDITS: 'quota:aiCredits', } as const; export const UNLIMITED_LICENSE_QUOTA = -1; @@ -159,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 */ export const LOWEST_SHUTDOWN_PRIORITY = 0; export const DEFAULT_SHUTDOWN_PRIORITY = 100; @@ -174,3 +193,6 @@ export const WsStatusCodes = { CloseAbnormal: 1006, CloseInvalidData: 1007, } as const; + +export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits'; +export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi'; diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts new file mode 100644 index 0000000000000..2cf0a1cdcd326 --- /dev/null +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -0,0 +1,112 @@ +import type { + AiAskRequestDto, + AiApplySuggestionRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { mock } from 'jest-mock-extended'; + +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import type { AuthenticatedRequest } from '@/requests'; +import type { AiService } from '@/services/ai.service'; + +import { AiController, type FlushableResponse } from '../ai.controller'; + +describe('AiController', () => { + const aiService = mock(); + + const controller = new AiController(aiService, mock(), mock()); + + const request = mock({ + user: { id: 'user123' }, + }); + const response = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + + response.header.mockReturnThis(); + }); + + describe('chat', () => { + const payload = mock(); + + it('should handle chat request successfully', async () => { + aiService.chat.mockResolvedValue( + mock({ + body: mock({ + pipeTo: jest.fn().mockImplementation(async (writableStream) => { + // Simulate stream writing + const writer = writableStream.getWriter(); + await writer.write(JSON.stringify({ message: 'test response' })); + await writer.close(); + }), + }), + }), + ); + + await controller.chat(request, response, payload); + + expect(aiService.chat).toHaveBeenCalledWith(payload, request.user); + expect(response.header).toHaveBeenCalledWith('Content-type', 'application/json-lines'); + expect(response.flush).toHaveBeenCalled(); + expect(response.end).toHaveBeenCalled(); + }); + + it('should throw InternalServerError if chat fails', async () => { + const mockError = new Error('Chat failed'); + + aiService.chat.mockRejectedValue(mockError); + + await expect(controller.chat(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); + + describe('applySuggestion', () => { + const payload = mock(); + + it('should apply suggestion successfully', async () => { + const clientResponse = mock(); + aiService.applySuggestion.mockResolvedValue(clientResponse); + + const result = await controller.applySuggestion(request, response, payload); + + expect(aiService.applySuggestion).toHaveBeenCalledWith(payload, request.user); + expect(result).toEqual(clientResponse); + }); + + it('should throw InternalServerError if applying suggestion fails', async () => { + const mockError = new Error('Apply suggestion failed'); + aiService.applySuggestion.mockRejectedValue(mockError); + + await expect(controller.applySuggestion(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); + + describe('askAi method', () => { + const payload = mock(); + + it('should ask AI successfully', async () => { + const clientResponse = mock(); + aiService.askAi.mockResolvedValue(clientResponse); + + const result = await controller.askAi(request, response, payload); + + expect(aiService.askAi).toHaveBeenCalledWith(payload, request.user); + expect(result).toEqual(clientResponse); + }); + + it('should throw InternalServerError if asking AI fails', async () => { + const mockError = new Error('Ask AI failed'); + aiService.askAi.mockRejectedValue(mockError); + + await expect(controller.askAi(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); +}); diff --git a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts index ff983cdd4ab77..87ed378a020ad 100644 --- a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts @@ -1,34 +1,223 @@ +import type { + OptionsRequestDto, + ResourceLocatorRequestDto, + ResourceMapperFieldsRequestDto, + ActionResultRequestDto, +} from '@n8n/api-types'; import { mock } from 'jest-mock-extended'; -import type { ILoadOptions, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import type { + ILoadOptions, + IWorkflowExecuteAdditionalData, + INodePropertyOptions, + NodeParameterValueType, +} from 'n8n-workflow'; import { DynamicNodeParametersController } from '@/controllers/dynamic-node-parameters.controller'; -import type { DynamicNodeParametersRequest } from '@/requests'; +import type { AuthenticatedRequest } from '@/requests'; import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; describe('DynamicNodeParametersController', () => { - const service = mock(); - const controller = new DynamicNodeParametersController(service); + let service: jest.Mocked; + let controller: DynamicNodeParametersController; + let mockUser: { id: string }; + let baseAdditionalData: IWorkflowExecuteAdditionalData; beforeEach(() => { - jest.clearAllMocks(); + service = mock(); + controller = new DynamicNodeParametersController(service); + + mockUser = { id: 'user123' }; + baseAdditionalData = mock(); + + jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(baseAdditionalData); }); describe('getOptions', () => { - it('should take `loadOptions` as object', async () => { - jest - .spyOn(AdditionalData, 'getBase') - .mockResolvedValue(mock()); + const basePayload: OptionsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }; + + it('should call getOptionsViaMethodName when methodName is provided', async () => { + const payload: OptionsRequestDto = { + ...basePayload, + methodName: 'testMethod', + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }]; + service.getOptionsViaMethodName.mockResolvedValue(expectedResult); + + const result = await controller.getOptions(req, mock(), payload); + + expect(service.getOptionsViaMethodName).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + + it('should call getOptionsViaLoadOptions when loadOptions is provided', async () => { + const loadOptions: ILoadOptions = { + routing: { + operations: {}, + }, + }; + const payload: OptionsRequestDto = { + ...basePayload, + loadOptions, + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }]; + service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult); + + const result = await controller.getOptions(req, mock(), payload); + + expect(service.getOptionsViaLoadOptions).toHaveBeenCalledWith( + loadOptions, + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + + it('should return empty array when no method or load options are provided', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const result = await controller.getOptions(req, mock(), basePayload); + + expect(result).toEqual([]); + }); + }); + + describe('getResourceLocatorResults', () => { + const basePayload: ResourceLocatorRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getResourceLocatorResults with correct parameters', async () => { + const payload: ResourceLocatorRequestDto = { + ...basePayload, + filter: 'testFilter', + paginationToken: 'testToken', + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { results: [{ name: 'test', value: 'value' }] }; + service.getResourceLocatorResults.mockResolvedValue(expectedResult); + + const result = await controller.getResourceLocatorResults(req, mock(), payload); + + expect(service.getResourceLocatorResults).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + 'testFilter', + 'testToken', + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getResourceMappingFields', () => { + const basePayload: ResourceMapperFieldsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getResourceMappingFields with correct parameters', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { fields: [] }; + service.getResourceMappingFields.mockResolvedValue(expectedResult); + + const result = await controller.getResourceMappingFields(req, mock(), basePayload); + + expect(service.getResourceMappingFields).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getLocalResourceMappingFields', () => { + const basePayload: ResourceMapperFieldsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getLocalResourceMappingFields with correct parameters', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { fields: [] }; + service.getLocalResourceMappingFields.mockResolvedValue(expectedResult); + + const result = await controller.getLocalResourceMappingFields(req, mock(), basePayload); + + expect(service.getLocalResourceMappingFields).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getActionResult', () => { + const basePayload: ActionResultRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + currentNodeParameters: {}, + }; - const req = mock(); - const loadOptions: ILoadOptions = {}; - req.body.loadOptions = loadOptions; + it('should call getActionResult with correct parameters', async () => { + const payload: ActionResultRequestDto = { + ...basePayload, + payload: { test: 'value' }, + }; + const req = { user: mockUser } as AuthenticatedRequest; - await controller.getOptions(req); + const expectedResult: NodeParameterValueType = 'test result'; + service.getActionResult.mockResolvedValue(expectedResult); - const zerothArg = service.getOptionsViaLoadOptions.mock.calls[0][0]; + const result = await controller.getActionResult(req, mock(), payload); - expect(zerothArg).toEqual(loadOptions); + expect(service.getActionResult).toHaveBeenCalledWith( + 'testHandler', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + { test: 'value' }, + undefined, + ); + expect(result).toEqual(expectedResult); }); }); }); diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index 0fd42aae438f1..b5065fa283688 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -1,7 +1,7 @@ +import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import type { Response } from 'express'; -import { anyObject, mock } from 'jest-mock-extended'; -import jwt from 'jsonwebtoken'; -import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import type { Logger } from 'n8n-core'; import type { AuthService } from '@/auth/auth.service'; import config from '@/config'; @@ -10,27 +10,31 @@ import type { User } from '@/databases/entities/user'; import type { SettingsRepository } from '@/databases/repositories/settings.repository'; import type { UserRepository } from '@/databases/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { License } from '@/license'; -import type { OwnerRequest } from '@/requests'; -import { PasswordUtility } from '@/services/password.utility'; +import type { EventService } from '@/events/event.service'; +import type { PublicUser } from '@/interfaces'; +import type { AuthenticatedRequest } from '@/requests'; +import type { PasswordUtility } from '@/services/password.utility'; import type { UserService } from '@/services/user.service'; -import { mockInstance } from '@test/mocking'; -import { badPasswords } from '@test/test-data'; describe('OwnerController', () => { const configGetSpy = jest.spyOn(config, 'getEnv'); + const configSetSpy = jest.spyOn(config, 'set'); + + const logger = mock(); + const eventService = mock(); const authService = mock(); const userService = mock(); const userRepository = mock(); const settingsRepository = mock(); - mockInstance(License).isWithinUsersLimit.mockReturnValue(true); + const passwordUtility = mock(); + const controller = new OwnerController( - mock(), - mock(), + logger, + eventService, settingsRepository, authService, userService, - Container.get(PasswordUtility), + passwordUtility, mock(), userRepository, ); @@ -38,38 +42,18 @@ describe('OwnerController', () => { describe('setupOwner', () => { it('should throw a BadRequestError if the instance owner is already setup', async () => { configGetSpy.mockReturnValue(true); - await expect(controller.setupOwner(mock(), mock())).rejects.toThrowError( + await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError( new BadRequestError('Instance owner already setup'), ); - }); - it('should throw a BadRequestError if the email is invalid', async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ body: { email: 'invalid email' } }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError('Invalid email address'), - ); - }); - - describe('should throw if the password is invalid', () => { - Object.entries(badPasswords).forEach(([password, errorMessage]) => { - it(password, async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ body: { email: 'valid@email.com', password } }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError(errorMessage), - ); - }); - }); - }); - - it('should throw a BadRequestError if firstName & lastName are missing ', async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ - body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' }, - }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError('First and last names are mandatory'), + expect(userRepository.findOneOrFail).not.toHaveBeenCalled(); + expect(userRepository.save).not.toHaveBeenCalled(); + expect(authService.issueCookie).not.toHaveBeenCalled(); + expect(settingsRepository.update).not.toHaveBeenCalled(); + expect(configSetSpy).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Request to claim instance ownership failed because instance owner already exists', ); }); @@ -80,29 +64,52 @@ describe('OwnerController', () => { authIdentities: [], }); const browserId = 'test-browser-id'; - const req = mock({ - body: { - email: 'valid@email.com', - password: 'NewPassword123', - firstName: 'Jane', - lastName: 'Doe', - }, - user, - browserId, - }); + const req = mock({ user, browserId }); const res = mock(); + const payload = mock({ + email: 'valid@email.com', + password: 'NewPassword123', + firstName: 'Jane', + lastName: 'Doe', + }); configGetSpy.mockReturnValue(false); - userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user); - userRepository.save.calledWith(anyObject()).mockResolvedValue(user); - jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + userRepository.findOneOrFail.mockResolvedValue(user); + userRepository.save.mockResolvedValue(user); + userService.toPublic.mockResolvedValue(mock({ id: 'newUserId' })); - await controller.setupOwner(req, res); + const result = await controller.setupOwner(req, res, payload); expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ where: { role: 'global:owner' }, }); expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false }); expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId); + expect(settingsRepository.update).toHaveBeenCalledWith( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true); + expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' }); + expect(result.id).toEqual('newUserId'); + }); + }); + + describe('dismissBanner', () => { + it('should not call dismissBanner if no banner is provided', async () => { + const payload = mock({ banner: undefined }); + + const result = await controller.dismissBanner(mock(), mock(), payload); + + expect(settingsRepository.dismissBanner).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should call dismissBanner with the correct banner name', async () => { + const payload = mock({ banner: 'TRIAL' }); + + await controller.dismissBanner(mock(), mock(), payload); + + expect(settingsRepository.dismissBanner).toHaveBeenCalledWith({ bannerName: 'TRIAL' }); }); }); }); diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index 91ad6bd28b299..5eaaca58406d2 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -4,7 +4,7 @@ import type { User } from '@/databases/entities/user'; import type { UserRepository } from '@/databases/repositories/user.repository'; import type { EventService } from '@/events/event.service'; import type { AuthenticatedRequest } from '@/requests'; -import type { ProjectService } from '@/services/project.service'; +import type { ProjectService } from '@/services/project.service.ee'; import { UsersController } from '../users.controller'; diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index be1231911a001..791c02bec3470 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -1,23 +1,37 @@ +import { + AiChatRequestDto, + AiApplySuggestionRequestDto, + AiAskRequestDto, + AiFreeCreditsRequestDto, +} from '@n8n/api-types'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; -import type { Response } from 'express'; +import { Response } from 'express'; import { strict as assert } from 'node:assert'; import { WritableStream } from 'node:stream/web'; -import { Post, RestController } from '@/decorators'; +import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { Body, Post, RestController } from '@/decorators'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { AiAssistantRequest } from '@/requests'; +import type { CredentialRequest } from '@/requests'; +import { AuthenticatedRequest } from '@/requests'; import { AiService } from '@/services/ai.service'; +import { UserService } from '@/services/user.service'; -type FlushableResponse = Response & { flush: () => void }; +export type FlushableResponse = Response & { flush: () => void }; @RestController('/ai') export class AiController { - constructor(private readonly aiService: AiService) {} + constructor( + private readonly aiService: AiService, + private readonly credentialsService: CredentialsService, + private readonly userService: UserService, + ) {} @Post('/chat', { rateLimit: { limit: 100 } }) - async chat(req: AiAssistantRequest.Chat, res: FlushableResponse) { + async chat(req: AuthenticatedRequest, res: FlushableResponse, @Body payload: AiChatRequestDto) { try { - const aiResponse = await this.aiService.chat(req.body, req.user); + const aiResponse = await this.aiService.chat(payload, req.user); if (aiResponse.body) { res.header('Content-type', 'application/json-lines').flush(); await aiResponse.body.pipeTo( @@ -38,10 +52,12 @@ export class AiController { @Post('/chat/apply-suggestion') async applySuggestion( - req: AiAssistantRequest.ApplySuggestionPayload, + req: AuthenticatedRequest, + _: Response, + @Body payload: AiApplySuggestionRequestDto, ): Promise { try { - return await this.aiService.applySuggestion(req.body, req.user); + return await this.aiService.applySuggestion(payload, req.user); } catch (e) { assert(e instanceof Error); throw new InternalServerError(e.message, e); @@ -49,9 +65,45 @@ export class AiController { } @Post('/ask-ai') - async askAi(req: AiAssistantRequest.AskAiPayload): Promise { + async askAi( + req: AuthenticatedRequest, + _: Response, + @Body payload: AiAskRequestDto, + ): Promise { try { - return await this.aiService.askAi(req.body, req.user); + return await this.aiService.askAi(payload, req.user); + } catch (e) { + assert(e instanceof Error); + throw new InternalServerError(e.message, e); + } + } + + @Post('/free-credits') + async aiCredits(req: AuthenticatedRequest, _: Response, @Body payload: AiFreeCreditsRequestDto) { + try { + const aiCredits = await this.aiService.createFreeAiCredits(req.user); + + const credentialProperties: CredentialRequest.CredentialProperties = { + name: FREE_AI_CREDITS_CREDENTIAL_NAME, + type: OPEN_AI_API_CREDENTIAL_TYPE, + data: { + apiKey: aiCredits.apiKey, + url: aiCredits.url, + }, + isManaged: true, + projectId: payload?.projectId, + }; + + const newCredential = await this.credentialsService.createCredential( + credentialProperties, + req.user, + ); + + await this.userService.updateSettings(req.user.id, { + userClaimedAiCredits: true, + }); + + return newCredential; } catch (e) { assert(e instanceof Error); throw new InternalServerError(e.message, e); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 46ee73a562c1a..fb06c1a80b96c 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,29 +1,28 @@ +import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types'; import { Response } from 'express'; -import { ApplicationError } from 'n8n-workflow'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Get, Post, RestController } from '@/decorators'; +import { Body, Get, Post, Query, RestController } from '@/decorators'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import type { PublicUser } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { PostHogClient } from '@/posthog'; -import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { UserService } from '@/services/user.service'; import { getCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, -} from '@/sso/sso-helpers'; +} from '@/sso.ee/sso-helpers'; @RestController() export class AuthController { @@ -40,10 +39,12 @@ export class AuthController { /** Log in a user */ @Post('/login', { skipAuth: true, rateLimit: true }) - async login(req: LoginRequest, res: Response): Promise { - const { email, password, mfaCode, mfaRecoveryCode } = req.body; - if (!email) throw new ApplicationError('Email is required to log in'); - if (!password) throw new ApplicationError('Password is required to log in'); + async login( + req: AuthlessRequest, + res: Response, + @Body payload: LoginRequestDto, + ): Promise { + const { email, password, mfaCode, mfaRecoveryCode } = payload; let user: User | undefined; @@ -117,8 +118,12 @@ export class AuthController { /** Validate invite token to enable invitee to set up their account */ @Get('/resolve-signup-token', { skipAuth: true }) - async resolveSignupToken(req: UserRequest.ResolveSignUp) { - const { inviterId, inviteeId } = req.query; + async resolveSignupToken( + _req: AuthlessRequest, + _res: Response, + @Query payload: ResolveSignupTokenQueryDto, + ) { + const { inviterId, inviteeId } = payload; const isWithinUsersLimit = this.license.isWithinUsersLimit(); if (!isWithinUsersLimit) { @@ -129,24 +134,6 @@ export class AuthController { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } - if (!inviterId || !inviteeId) { - this.logger.debug( - 'Request to resolve signup token failed because of missing user IDs in query string', - { inviterId, inviteeId }, - ); - throw new BadRequestError('Invalid payload'); - } - - // Postgres validates UUID format - for (const userId of [inviterId, inviteeId]) { - if (!validator.isUUID(userId)) { - this.logger.debug('Request to resolve signup token failed because of invalid user ID', { - userId, - }); - throw new BadRequestError('Invalid userId'); - } - } - const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); if (users.length !== 2) { diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index 2858fd99cae9d..987040d010671 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -1,8 +1,13 @@ +import { + OptionsRequestDto, + ResourceLocatorRequestDto, + ResourceMapperFieldsRequestDto, + ActionResultRequestDto, +} from '@n8n/api-types'; import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow'; -import { Post, RestController } from '@/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { DynamicNodeParametersRequest } from '@/requests'; +import { Post, RestController, Body } from '@/decorators'; +import { AuthenticatedRequest } from '@/requests'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import { getBase } from '@/workflow-execute-additional-data'; @@ -11,7 +16,11 @@ export class DynamicNodeParametersController { constructor(private readonly service: DynamicNodeParametersService) {} @Post('/options') - async getOptions(req: DynamicNodeParametersRequest.Options): Promise { + async getOptions( + req: AuthenticatedRequest, + _res: Response, + @Body payload: OptionsRequestDto, + ): Promise { const { credentials, currentNodeParameters, @@ -19,7 +28,7 @@ export class DynamicNodeParametersController { path, methodName, loadOptions, - } = req.body; + } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -48,7 +57,11 @@ export class DynamicNodeParametersController { } @Post('/resource-locator-results') - async getResourceLocatorResults(req: DynamicNodeParametersRequest.ResourceLocatorResults) { + async getResourceLocatorResults( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceLocatorRequestDto, + ) { const { path, methodName, @@ -57,9 +70,7 @@ export class DynamicNodeParametersController { credentials, currentNodeParameters, nodeTypeAndVersion, - } = req.body; - - if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -76,10 +87,12 @@ export class DynamicNodeParametersController { } @Post('/resource-mapper-fields') - async getResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { - const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = req.body; - - if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + async getResourceMappingFields( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceMapperFieldsRequestDto, + ) { + const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -94,10 +107,12 @@ export class DynamicNodeParametersController { } @Post('/local-resource-mapper-fields') - async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { - const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body; - - if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + async getLocalResourceMappingFields( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceMapperFieldsRequestDto, + ) { + const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -111,25 +126,29 @@ export class DynamicNodeParametersController { @Post('/action-result') async getActionResult( - req: DynamicNodeParametersRequest.ActionResult, + req: AuthenticatedRequest, + _res: Response, + @Body payload: ActionResultRequestDto, ): Promise { - const { currentNodeParameters, nodeTypeAndVersion, path, credentials, handler, payload } = - req.body; + const { + currentNodeParameters, + nodeTypeAndVersion, + path, + credentials, + handler, + payload: actionPayload, + } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); - if (handler) { - return await this.service.getActionResult( - handler, - path, - additionalData, - nodeTypeAndVersion, - currentNodeParameters, - payload, - credentials, - ); - } - - return; + return await this.service.getActionResult( + handler, + path, + additionalData, + nodeTypeAndVersion, + currentNodeParameters, + actionPayload, + credentials, + ); } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index a61342320dd04..aa0226c754892 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -1,5 +1,6 @@ import type { PushMessage } from '@n8n/api-types'; import { Request } from 'express'; +import { Logger } from 'n8n-core'; import Container from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -14,10 +15,8 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import type { BooleanLicenseFeature, NumericLicenseFeature } from '@/interfaces'; import type { FeatureReturnType } from '@/license'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { Push } from '@/push'; -import type { UserSetupPayload } from '@/requests'; import { CacheService } from '@/services/cache/cache.service'; import { PasswordUtility } from '@/services/password.utility'; @@ -48,6 +47,16 @@ const tablesToTruncate = [ 'workflows_tags', ]; +type UserSetupPayload = { + email: string; + password: string; + firstName: string; + lastName: string; + mfaEnabled?: boolean; + mfaSecret?: string; + mfaRecoveryCodes?: string[]; +}; + type ResetRequest = Request< {}, {}, @@ -91,6 +100,7 @@ export class E2EController { [LICENSE_FEATURES.AI_ASSISTANT]: false, [LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false, [LICENSE_FEATURES.ASK_AI]: false, + [LICENSE_FEATURES.AI_CREDITS]: false, }; private numericFeatures: Record = { @@ -99,6 +109,7 @@ export class E2EController { [LICENSE_QUOTAS.USERS_LIMIT]: -1, [LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1, [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0, + [LICENSE_QUOTAS.AI_CREDITS]: 0, }; constructor( diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index cbd2afb9f432c..d8a0e503f6d90 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -1,23 +1,23 @@ +import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Post, GlobalScope, RestController } from '@/decorators'; +import { Post, GlobalScope, RestController, Body, Param } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { UserRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; +import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; @RestController('/invitations') export class InvitationController { @@ -39,7 +39,13 @@ export class InvitationController { @Post('/', { rateLimit: { limit: 10 } }) @GlobalScope('user:create') - async inviteUser(req: UserRequest.Invite) { + async inviteUser( + req: AuthenticatedRequest, + _res: Response, + @Body invitations: InviteUsersRequestDto, + ) { + if (invitations.length === 0) return []; + const isWithinUsersLimit = this.license.isWithinUsersLimit(); if (isSamlLicensedAndEnabled()) { @@ -65,50 +71,15 @@ export class InvitationController { throw new BadRequestError('You must set up your own account before inviting others'); } - if (!Array.isArray(req.body)) { - this.logger.debug( - 'Request to send email invite(s) to user(s) failed because the payload is not an array', - { - payload: req.body, - }, - ); - throw new BadRequestError('Invalid payload'); - } - - if (!req.body.length) return []; - - req.body.forEach((invite) => { - if (typeof invite !== 'object' || !invite.email) { - throw new BadRequestError( - 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', - ); - } - - if (!validator.isEmail(invite.email)) { - this.logger.debug('Invalid email in payload', { invalidEmail: invite.email }); - throw new BadRequestError( - `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, - ); - } - - if (invite.role && !['global:member', 'global:admin'].includes(invite.role)) { - throw new BadRequestError( - `Cannot invite user with invalid role: ${invite.role}. Please ensure all invitees' roles are either 'global:member' or 'global:admin'.`, - ); - } - - if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { + const attributes = invitations.map(({ email, role }) => { + if (role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { throw new ForbiddenError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); } + return { email, role }; }); - const attributes = req.body.map(({ email, role }) => ({ - email, - role: role ?? 'global:member', - })); - const { usersInvited, usersCreated } = await this.userService.inviteUsers(req.user, attributes); await this.externalHooks.run('user.invited', [usersCreated]); @@ -120,20 +91,13 @@ export class InvitationController { * Fill out user shell with first name, last name, and password. */ @Post('/:id/accept', { skipAuth: true }) - async acceptInvitation(req: UserRequest.Update, res: Response) { - const { id: inviteeId } = req.params; - - const { inviterId, firstName, lastName, password } = req.body; - - if (!inviterId || !inviteeId || !firstName || !lastName || !password) { - this.logger.debug( - 'Request to fill out a user shell failed because of missing properties in payload', - { payload: req.body }, - ); - throw new BadRequestError('Invalid payload'); - } - - const validPassword = this.passwordUtility.validate(password); + async acceptInvitation( + req: AuthlessRequest, + res: Response, + @Body payload: AcceptInvitationRequestDto, + @Param('id') inviteeId: string, + ) { + const { inviterId, firstName, lastName, password } = payload; const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); @@ -160,7 +124,7 @@ export class InvitationController { invitee.firstName = firstName; invitee.lastName = lastName; - invitee.password = await this.passwordUtility.hash(validPassword); + invitee.password = await this.passwordUtility.hash(password); const updatedUser = await this.userRepository.save(invitee, { transaction: false }); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index a7fb7235fddd5..bb42d1387898a 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -1,10 +1,12 @@ import { + passwordSchema, PasswordUpdateRequestDto, SettingsUpdateRequestDto, UserUpdateRequestDto, } from '@n8n/api-types'; import { plainToInstance } from 'class-transformer'; import { Response } from 'express'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import type { User } from '@/databases/entities/user'; @@ -16,12 +18,11 @@ import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { AuthenticatedRequest, MeRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; +import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; @RestController('/me') @@ -122,10 +123,6 @@ export class MeController { ); } - if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { - throw new BadRequestError('Invalid payload.'); - } - if (!user.password) { throw new BadRequestError('Requesting user not set up.'); } @@ -135,7 +132,12 @@ export class MeController { throw new BadRequestError('Provided current password is incorrect.'); } - const validPassword = this.passwordUtility.validate(newPassword); + const passwordValidation = passwordSchema.safeParse(newPassword); + if (!passwordValidation.success) { + throw new BadRequestError( + passwordValidation.error.errors.map(({ message }) => message).join(' '), + ); + } if (user.mfaEnabled) { if (typeof mfaCode !== 'string') { @@ -148,7 +150,7 @@ export class MeController { } } - user.password = await this.passwordUtility.hash(validPassword); + user.password = await this.passwordUtility.hash(newPassword); const updatedUser = await this.userRepository.save(user, { transaction: false }); this.logger.info('Password updated successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 2d76642266c51..0c51f29f22f64 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -2,6 +2,7 @@ import Csrf from 'csrf'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; +import { Logger } from 'n8n-core'; import nock from 'nock'; import Container from 'typedi'; @@ -12,13 +13,12 @@ import type { CredentialsEntity } from '@/databases/entities/credentials-entity' import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import type { OAuthRequest } from '@/requests'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { mockInstance } from '@test/mocking'; describe('OAuth1CredentialController', () => { diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index b2bd987fb0836..c1c240425e65e 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -2,6 +2,7 @@ import Csrf from 'csrf'; import { type Response } from 'express'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; +import { Logger } from 'n8n-core'; import nock from 'nock'; import Container from 'typedi'; @@ -12,13 +13,12 @@ import type { CredentialsEntity } from '@/databases/entities/credentials-entity' import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import type { OAuthRequest } from '@/requests'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { mockInstance } from '@test/mocking'; describe('OAuth2CredentialController', () => { diff --git a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts index 3f5c20dfc3c8c..97fb7be24af28 100644 --- a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; import Csrf from 'csrf'; import type { Response } from 'express'; -import { Credentials } from 'n8n-core'; +import { Credentials, Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import { jsonParse, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -16,7 +16,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { AuthenticatedRequest, OAuthRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 47d50ad3f042d..5c7e8d1e2ac84 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,17 +1,17 @@ +import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { GlobalScope, Post, RestController } from '@/decorators'; +import { Body, GlobalScope, Post, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; import { validateEntity } from '@/generic-helpers'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { OwnerRequest } from '@/requests'; +import { AuthenticatedRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; @@ -33,8 +33,8 @@ export class OwnerController { * and enable `isInstanceOwnerSetUp` setting. */ @Post('/setup', { skipAuth: true }) - async setupOwner(req: OwnerRequest.Post, res: Response) { - const { email, firstName, lastName, password } = req.body; + async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) { + const { email, firstName, lastName, password } = payload; if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { this.logger.debug( @@ -43,31 +43,15 @@ export class OwnerController { throw new BadRequestError('Instance owner already setup'); } - if (!email || !validator.isEmail(email)) { - this.logger.debug('Request to claim instance ownership failed because of invalid email', { - invalidEmail: email, - }); - throw new BadRequestError('Invalid email address'); - } - - const validPassword = this.passwordUtility.validate(password); - - if (!firstName || !lastName) { - this.logger.debug( - 'Request to claim instance ownership failed because of missing first name or last name in payload', - { payload: req.body }, - ); - throw new BadRequestError('First and last names are mandatory'); - } - let owner = await this.userRepository.findOneOrFail({ where: { role: 'global:owner' }, }); owner.email = email; owner.firstName = firstName; owner.lastName = lastName; - owner.password = await this.passwordUtility.hash(validPassword); + owner.password = await this.passwordUtility.hash(password); + // TODO: move XSS validation out into the DTO class await validateEntity(owner); owner = await this.userRepository.save(owner, { transaction: false }); @@ -92,8 +76,13 @@ export class OwnerController { @Post('/dismiss-banner') @GlobalScope('banner:dismiss') - async dismissBanner(req: OwnerRequest.DismissBanner) { - const bannerName = 'banner' in req.body ? (req.body.banner as string) : ''; + async dismissBanner( + _req: AuthenticatedRequest, + _res: Response, + @Body payload: DismissBannerRequestDto, + ) { + const bannerName = payload.banner; + if (!bannerName) return; return await this.settingsRepository.dismissBanner({ bannerName }); } } diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index 2179ff3d9e737..c2652aa785891 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -1,10 +1,15 @@ +import { + ChangePasswordRequestDto, + ForgotPasswordRequestDto, + ResolvePasswordTokenQueryDto, +} from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Get, Post, RestController } from '@/decorators'; +import { Body, Get, Post, Query, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -13,12 +18,11 @@ import { UnprocessableRequestError } from '@/errors/response-errors/unprocessabl import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; -import { PasswordResetRequest } from '@/requests'; +import { AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; @RestController() @@ -40,7 +44,11 @@ export class PasswordResetController { * Send a password reset email. */ @Post('/forgot-password', { skipAuth: true, rateLimit: { limit: 3 } }) - async forgotPassword(req: PasswordResetRequest.Email) { + async forgotPassword( + _req: AuthlessRequest, + _res: Response, + @Body payload: ForgotPasswordRequestDto, + ) { if (!this.mailer.isEmailSetUp) { this.logger.debug( 'Request to send password reset email failed because emailing was not set up', @@ -50,22 +58,7 @@ export class PasswordResetController { ); } - const { email } = req.body; - if (!email) { - this.logger.debug( - 'Request to send password reset email failed because of missing email in payload', - { payload: req.body }, - ); - throw new BadRequestError('Email is mandatory'); - } - - if (!validator.isEmail(email)) { - this.logger.debug( - 'Request to send password reset email failed because of invalid email in payload', - { invalidEmail: email }, - ); - throw new BadRequestError('Invalid email address'); - } + const { email } = payload; // User should just be able to reset password if one is already present const user = await this.userRepository.findNonShellUser(email); @@ -138,19 +131,12 @@ export class PasswordResetController { * Verify password reset token and user ID. */ @Get('/resolve-password-token', { skipAuth: true }) - async resolvePasswordToken(req: PasswordResetRequest.Credentials) { - const { token } = req.query; - - if (!token) { - this.logger.debug( - 'Request to resolve password token failed because of missing password reset token', - { - queryString: req.query, - }, - ); - throw new BadRequestError(''); - } - + async resolvePasswordToken( + _req: AuthlessRequest, + _res: Response, + @Query payload: ResolvePasswordTokenQueryDto, + ) { + const { token } = payload; const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); @@ -170,20 +156,12 @@ export class PasswordResetController { * Verify password reset token and update password. */ @Post('/change-password', { skipAuth: true }) - async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { - const { token, password, mfaCode } = req.body; - - if (!token || !password) { - this.logger.debug( - 'Request to change password failed because of missing user ID or password or reset password token in payload', - { - payload: req.body, - }, - ); - throw new BadRequestError('Missing user ID or password or reset password token'); - } - - const validPassword = this.passwordUtility.validate(password); + async changePassword( + req: AuthlessRequest, + res: Response, + @Body payload: ChangePasswordRequestDto, + ) { + const { token, password, mfaCode } = payload; const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); @@ -198,7 +176,7 @@ export class PasswordResetController { if (!validToken) throw new BadRequestError('Invalid MFA token.'); } - const passwordHash = await this.passwordUtility.hash(validPassword); + const passwordHash = await this.passwordUtility.hash(password); await this.userService.update(user.id, { password: passwordHash }); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 415e0e9519130..ab48f38c5bf96 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -23,7 +23,7 @@ import { ProjectService, TeamProjectOverQuotaError, UnlicensedProjectRoleError, -} from '@/services/project.service'; +} from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; @RestController('/projects') @@ -51,7 +51,12 @@ export class ProjectController { @Licensed('feat:projectRole:admin') async createProject(req: ProjectRequest.Create) { try { - const project = await this.projectsService.createTeamProject(req.body.name, req.user); + const project = await this.projectsService.createTeamProject( + req.body.name, + req.user, + undefined, + req.body.icon, + ); this.eventService.emit('team-project-created', { userId: req.user.id, @@ -163,7 +168,7 @@ export class ProjectController { @Get('/:projectId') @ProjectScope('project:read') async getProject(req: ProjectRequest.Get): Promise { - const [{ id, name, type }, relations] = await Promise.all([ + const [{ id, name, icon, type }, relations] = await Promise.all([ this.projectsService.getProject(req.params.projectId), this.projectsService.getProjectRelations(req.params.projectId), ]); @@ -172,6 +177,7 @@ export class ProjectController { return { id, name, + icon, type, relations: relations.map((r) => ({ id: r.user.id, @@ -193,7 +199,7 @@ export class ProjectController { @ProjectScope('project:update') async updateProject(req: ProjectRequest.Update) { if (req.body.name) { - await this.projectsService.updateProject(req.body.name, req.params.projectId); + await this.projectsService.updateProject(req.body.name, req.params.projectId, req.body.icon); } if (req.body.relations) { try { diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 8e19be894d438..3177c2c23be63 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -1,5 +1,6 @@ import { RoleChangeRequestDto, SettingsUpdateRequestDto } from '@n8n/api-types'; import { Response } from 'express'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -10,18 +11,25 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { GlobalScope, Delete, Get, RestController, Patch, Licensed, Body } from '@/decorators'; -import { Param } from '@/decorators/args'; +import { + GlobalScope, + Delete, + Get, + RestController, + Patch, + Licensed, + Body, + Param, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import type { PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; import { AuthenticatedRequest, ListQuery, UserRequest } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { UserService } from '@/services/user.service'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/controllers/workflow-statistics.controller.ts b/packages/cli/src/controllers/workflow-statistics.controller.ts index 58c99727db1df..b14afc9179c1e 100644 --- a/packages/cli/src/controllers/workflow-statistics.controller.ts +++ b/packages/cli/src/controllers/workflow-statistics.controller.ts @@ -1,4 +1,5 @@ import { Response, NextFunction } from 'express'; +import { Logger } from 'n8n-core'; import type { WorkflowStatistics } from '@/databases/entities/workflow-statistics'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; @@ -7,7 +8,6 @@ import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow- import { Get, Middleware, RestController } from '@/decorators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IWorkflowStatisticsDataLoaded } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { StatisticsRequest } from './workflow-statistics.types'; diff --git a/packages/cli/src/crash-journal.ts b/packages/cli/src/crash-journal.ts index 577a2f34fe9d0..8afae1e88c96a 100644 --- a/packages/cli/src/crash-journal.ts +++ b/packages/cli/src/crash-journal.ts @@ -1,12 +1,11 @@ import { existsSync } from 'fs'; import { mkdir, utimes, open, rm } from 'fs/promises'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import { join, dirname } from 'path'; import { Container } from 'typedi'; import { inProduction } from '@/constants'; -import { Logger } from '@/logging/logger.service'; export const touchFile = async (filePath: string): Promise => { await mkdir(dirname(filePath), { recursive: true }); diff --git a/packages/cli/src/credentials-overwrites.ts b/packages/cli/src/credentials-overwrites.ts index ed1b492dc68ea..30f6bedfb8ba0 100644 --- a/packages/cli/src/credentials-overwrites.ts +++ b/packages/cli/src/credentials-overwrites.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { deepCopy, jsonParse } from 'n8n-workflow'; import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; @Service() export class CredentialsOverwrites { diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts new file mode 100644 index 0000000000000..13e72e80034fa --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -0,0 +1,82 @@ +import { mock } from 'jest-mock-extended'; + +import { createRawProjectData } from '@/__tests__/project.test-data'; +import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import type { EventService } from '@/events/event.service'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data'; +import { CredentialsController } from '../credentials.controller'; +import type { CredentialsService } from '../credentials.service'; + +describe('CredentialsController', () => { + const eventService = mock(); + const credentialsService = mock(); + const sharedCredentialsRepository = mock(); + + const credentialsController = new CredentialsController( + mock(), + credentialsService, + mock(), + mock(), + mock(), + mock(), + mock(), + sharedCredentialsRepository, + mock(), + eventService, + ); + + let req: AuthenticatedRequest; + beforeAll(() => { + req = { user: { id: '123' } } as AuthenticatedRequest; + }); + + describe('createCredentials', () => { + it('it should create new credentials and emit "credentials-created"', async () => { + // Arrange + + const newCredentialsPayload = createNewCredentialsPayload(); + + req.body = newCredentialsPayload; + + const { data, ...payloadWithoutData } = newCredentialsPayload; + + const createdCredentials = createdCredentialsWithScopes(payloadWithoutData); + + const projectOwningCredentialData = createRawProjectData({ + id: newCredentialsPayload.projectId, + }); + + credentialsService.createCredential.mockResolvedValue(createdCredentials); + + sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue( + projectOwningCredentialData, + ); + + // Act + + const newApiKey = await credentialsController.createCredentials(req); + + // Assert + + expect(credentialsService.createCredential).toHaveBeenCalledWith( + newCredentialsPayload, + req.user, + ); + expect(sharedCredentialsRepository.findCredentialOwningProject).toHaveBeenCalledWith( + createdCredentials.id, + ); + expect(eventService.emit).toHaveBeenCalledWith('credentials-created', { + user: expect.objectContaining({ id: req.user.id }), + credentialId: createdCredentials.id, + credentialType: createdCredentials.type, + projectId: projectOwningCredentialData.id, + projectType: projectOwningCredentialData.type, + publicApi: false, + }); + + expect(newApiKey).toEqual(createdCredentials); + }); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 4d1cbd5256ca3..526acfc17d777 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -1,10 +1,17 @@ import { mock } from 'jest-mock-extended'; +import { nanoId, date } from 'minifaker'; +import { Credentials } from 'n8n-core'; import { CREDENTIAL_EMPTY_VALUE, type ICredentialType } from 'n8n-workflow'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import type { CredentialTypes } from '@/credential-types'; import { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createNewCredentialsPayload, credentialScopes } from './credentials.test-data'; + +let req = { user: { id: '123' } } as AuthenticatedRequest; describe('CredentialsService', () => { const credType = mock({ @@ -24,6 +31,7 @@ describe('CredentialsService', () => { }, ], }); + const credentialTypes = mock(); const service = new CredentialsService( mock(), @@ -55,7 +63,7 @@ describe('CredentialsService', () => { csrfSecret: 'super-secret', }; - credentialTypes.getByName.calledWith(credential.type).mockReturnValue(credType); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); const redactedData = service.redact(decryptedData, credential); @@ -68,4 +76,123 @@ describe('CredentialsService', () => { }); }); }); + + describe('createCredential', () => { + it('it should create new credentials and return with scopes', async () => { + // Arrange + + const encryptedData = 'encryptedData'; + + const newCredentialPayloadData = createNewCredentialsPayload(); + + const newCredential = mock({ + name: newCredentialPayloadData.name, + data: JSON.stringify(newCredentialPayloadData.data), + type: newCredentialPayloadData.type, + }); + + const encryptedDataResponse = { + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: date(), + data: encryptedData, + }; + + const saveCredentialsResponse = { + id: nanoId.nanoid(), + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: encryptedDataResponse.updatedAt, + createdAt: date(), + data: encryptedDataResponse.data, + isManaged: false, + shared: undefined, + }; + + service.prepareCreateData = jest.fn().mockReturnValue(newCredential); + service.createEncryptedData = jest.fn().mockImplementation(() => encryptedDataResponse); + service.save = jest.fn().mockResolvedValue(saveCredentialsResponse); + service.getCredentialScopes = jest.fn().mockReturnValue(credentialScopes); + + // Act + + const createdCredential = await service.createCredential(newCredentialPayloadData, req.user); + + // Assert + + expect(service.prepareCreateData).toHaveBeenCalledWith(newCredentialPayloadData); + expect(service.createEncryptedData).toHaveBeenCalledWith(null, newCredential); + expect(service.save).toHaveBeenCalledWith( + newCredential, + encryptedDataResponse, + req.user, + newCredentialPayloadData.projectId, + ); + expect(service.getCredentialScopes).toHaveBeenCalledWith( + req.user, + saveCredentialsResponse.id, + ); + + expect(createdCredential).toEqual({ + ...saveCredentialsResponse, + scopes: credentialScopes, + }); + }); + }); + + describe('decrypt', () => { + it('should redact sensitive values by default', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential); + + // ASSERT + expect(redactedData).toEqual({ + clientId: 'abc123', + clientSecret: CREDENTIAL_BLANKING_VALUE, + accessToken: CREDENTIAL_EMPTY_VALUE, + oauthTokenData: CREDENTIAL_BLANKING_VALUE, + csrfSecret: CREDENTIAL_BLANKING_VALUE, + }); + }); + + it('should return sensitive values if `includeRawData` is true', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential, true); + + // ASSERT + expect(redactedData).toEqual(data); + }); + }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.test-data.ts b/packages/cli/src/credentials/__tests__/credentials.test-data.ts new file mode 100644 index 0000000000000..8bbbbf3553bc5 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.test-data.ts @@ -0,0 +1,62 @@ +import type { Scope } from '@n8n/permissions'; +import { nanoId, date } from 'minifaker'; +import { randomString } from 'n8n-workflow'; + +import type { CredentialRequest } from '@/requests'; + +type NewCredentialWithSCopes = { + scopes: Scope[]; + name: string; + data: string; + type: string; + isManaged: boolean; + id: string; + createdAt: Date; + updatedAt: Date; +}; + +const name = 'new Credential'; +const type = 'openAiApi'; +const data = { + apiKey: 'apiKey', + url: 'url', +}; +const projectId = nanoId.nanoid(); + +export const credentialScopes: Scope[] = [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', +]; + +export const createNewCredentialsPayload = ( + payload?: Partial, +): CredentialRequest.CredentialProperties => { + return { + name, + type, + data, + projectId, + ...payload, + }; +}; + +export const createdCredentialsWithScopes = ( + payload?: Partial, +): NewCredentialWithSCopes => { + return { + name, + type, + data: randomString(20), + id: nanoId.nanoid(), + createdAt: date(), + updatedAt: date(), + isManaged: false, + scopes: credentialScopes, + ...payload, + }; +}; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 76db501cf7959..4cc0b500f2e0b 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -1,6 +1,8 @@ +import { CredentialsGetManyRequestQuery, CredentialsGetOneRequestQuery } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; +import { Logger } from 'n8n-core'; import { deepCopy } from 'n8n-workflow'; import { z } from 'zod'; @@ -18,12 +20,12 @@ import { RestController, ProjectScope, } from '@/decorators'; +import { Param, Query } from '@/decorators/args'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; import { CredentialRequest } from '@/requests'; import { NamingService } from '@/services/naming.service'; @@ -49,10 +51,15 @@ export class CredentialsController { ) {} @Get('/', { middlewares: listQueryMiddleware }) - async getMany(req: CredentialRequest.GetMany) { + async getMany( + req: CredentialRequest.GetMany, + _res: unknown, + @Query query: CredentialsGetManyRequestQuery, + ) { const credentials = await this.credentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions, - includeScopes: req.query.includeScopes, + includeScopes: query.includeScopes, + includeData: query.includeData, }); credentials.forEach((c) => { // @ts-expect-error: This is to emulate the old behavior of removing the shared @@ -82,21 +89,22 @@ export class CredentialsController { @Get('/:credentialId') @ProjectScope('credential:read') - async getOne(req: CredentialRequest.Get) { + async getOne( + req: CredentialRequest.Get, + _res: unknown, + @Param('credentialId') credentialId: string, + @Query query: CredentialsGetOneRequestQuery, + ) { const { shared, ...credential } = this.license.isSharingEnabled() ? await this.enterpriseCredentialsService.getOne( req.user, - req.params.credentialId, + credentialId, // TODO: editor-ui is always sending this, maybe we can just rely on the // the scopes and always decrypt the data if the user has the permissions // to do so. - req.query.includeData === 'true', + query.includeData, ) - : await this.credentialsService.getOne( - req.user, - req.params.credentialId, - req.query.includeData === 'true', - ); + : await this.credentialsService.getOne(req.user, credentialId, query.includeData); const scopes = await this.credentialsService.getCredentialScopes( req.user, @@ -147,32 +155,22 @@ export class CredentialsController { @Post('/') async createCredentials(req: CredentialRequest.Create) { - const newCredential = await this.credentialsService.prepareCreateData(req.body); - - const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); - const { shared, ...credential } = await this.credentialsService.save( - newCredential, - encryptedData, - req.user, - req.body.projectId, - ); + const newCredential = await this.credentialsService.createCredential(req.body, req.user); const project = await this.sharedCredentialsRepository.findCredentialOwningProject( - credential.id, + newCredential.id, ); this.eventService.emit('credentials-created', { user: req.user, - credentialType: credential.type, - credentialId: credential.id, + credentialType: newCredential.type, + credentialId: newCredential.id, publicApi: false, projectId: project?.id, projectType: project?.type, }); - const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); - - return { ...credential, scopes }; + return newCredential; } @Patch('/:credentialId') @@ -196,6 +194,10 @@ export class CredentialsController { ); } + if (credential.isManaged) { + throw new BadRequestError('Managed credentials cannot be updated'); + } + const decryptedData = this.credentialsService.decrypt(credential); const preparedCredentialData = await this.credentialsService.prepareUpdateData( req.body, diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index aad78fe7b7303..949748600db5e 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -11,7 +11,7 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { CredentialsService } from './credentials.service'; @@ -87,10 +87,7 @@ export class EnterpriseCredentialsService { if (credential) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, - ); + decryptedData = this.credentialsService.decrypt(credential); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index f9bbf89e57cdb..e7e0447417e4f 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -6,7 +6,7 @@ import { type FindOptionsRelations, type FindOptionsWhere, } from '@n8n/typeorm'; -import { Credentials } from 'n8n-core'; +import { Credentials, Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject, ICredentialsDecrypted, @@ -33,12 +33,12 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; +import type { ScopesField } from '@/services/role.service'; import { RoleService } from '@/services/role.service'; export type CredentialsGetSharedOptions = @@ -63,33 +63,47 @@ export class CredentialsService { async getMany( user: User, - options: { - listQueryOptions?: ListQuery.Options; - includeScopes?: string; + { + listQueryOptions = {}, + includeScopes = false, + includeData = false, + }: { + listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + includeScopes?: boolean; + includeData?: boolean; } = {}, ) { const returnAll = user.hasGlobalScope('credential:list'); - const isDefaultSelect = !options.listQueryOptions?.select; + const isDefaultSelect = !listQueryOptions.select; + + if (includeData) { + // We need the scopes to check if we're allowed to include the decrypted + // data. + // Only if the user has the `credential:update` scope the user is allowed + // to get the data. + includeScopes = true; + listQueryOptions.includeData = true; + } let projectRelations: ProjectRelation[] | undefined = undefined; - if (options.includeScopes) { + if (includeScopes) { projectRelations = await this.projectService.getProjectRelationsForUser(user); - if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) { + if (listQueryOptions.filter?.projectId && user.hasGlobalScope('credential:list')) { // Only instance owners and admins have the credential:list scope // Those users should be able to use _all_ credentials within their workflows. // TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change const projectRelation = projectRelations.find( - (relation) => relation.projectId === options.listQueryOptions?.filter?.projectId, + (relation) => relation.projectId === listQueryOptions.filter?.projectId, ); if (projectRelation?.role === 'project:personalOwner') { // Will not affect team projects as these have admins, not owners. - delete options.listQueryOptions?.filter?.projectId; + delete listQueryOptions.filter?.projectId; } } } if (returnAll) { - let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); + let credentials = await this.credentialsRepository.findMany(listQueryOptions); if (isDefaultSelect) { // Since we're filtering using project ID as part of the relation, @@ -97,7 +111,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -108,23 +122,32 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!), ); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } - // If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to. - if (typeof options.listQueryOptions?.filter?.projectId === 'string') { - const project = await this.projectService.getProject( - options.listQueryOptions.filter.projectId, - ); + // If the workflow is part of a personal project we want to show the + // credentials the user making the request has access to, not the + // credentials the user owning the workflow has access to. + if (typeof listQueryOptions.filter?.projectId === 'string') { + const project = await this.projectService.getProject(listQueryOptions.filter.projectId); if (project?.type === 'personal') { const currentUsersPersonalProject = await this.projectService.getPersonalProject(user); - options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; + listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; } } @@ -133,7 +156,7 @@ export class CredentialsService { }); let credentials = await this.credentialsRepository.findMany( - options.listQueryOptions, + listQueryOptions, ids, // only accessible credentials ); @@ -143,7 +166,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -155,10 +178,19 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!)); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } @@ -197,6 +229,7 @@ export class CredentialsService { name: c.name, type: c.type, scopes: c.scopes, + isManaged: c.isManaged, })); } @@ -308,9 +341,18 @@ export class CredentialsService { return newCredentialData; } - decrypt(credential: CredentialsEntity) { + /** + * Decrypts the credentials data and redacts the content by default. + * + * If `includeRawData` is set to true it will not redact the data. + */ + decrypt(credential: CredentialsEntity, includeRawData = false) { const coreCredential = createCredentialsFromCredentialsEntity(credential); - return coreCredential.getData(); + const data = coreCredential.getData(); + if (includeRawData) { + return data; + } + return this.redact(data, credential); } async update(credentialId: string, newCredentialData: ICredentialsDb) { @@ -500,7 +542,7 @@ export class CredentialsService { if (sharing) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials); + decryptedData = this.decrypt(sharing.credentials); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. @@ -603,4 +645,25 @@ export class CredentialsService { mergedCredentials.data = decryptedData; } } + + /** + * Create a new credential in user's account and return it along the scopes + * If a projectId is send, then it also binds the credential to that specific project + */ + async createCredential(credentialsData: CredentialRequest.CredentialProperties, user: User) { + const newCredential = await this.prepareCreateData(credentialsData); + + const encryptedData = this.createEncryptedData(null, newCredential); + + const { shared, ...credential } = await this.save( + newCredential, + encryptedData, + user, + credentialsData.projectId, + ); + + const scopes = await this.getCredentialScopes(user, credential.id); + + return { ...credential, scopes }; + } } diff --git a/packages/cli/src/databases/entities/project.ts b/packages/cli/src/databases/entities/project.ts index 88c4ed009a26c..aa867807fd2ae 100644 --- a/packages/cli/src/databases/entities/project.ts +++ b/packages/cli/src/databases/entities/project.ts @@ -6,6 +6,7 @@ import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; export type ProjectType = 'personal' | 'team'; +export type ProjectIcon = { type: 'emoji' | 'icon'; value: string } | null; @Entity() export class Project extends WithTimestampsAndStringId { @@ -15,6 +16,9 @@ export class Project extends WithTimestampsAndStringId { @Column({ length: 36 }) type: ProjectType; + @Column({ type: 'json', nullable: true }) + icon: ProjectIcon; + @OneToMany('ProjectRelation', 'project') projectRelations: ProjectRelation[]; diff --git a/packages/cli/src/databases/entities/user.ts b/packages/cli/src/databases/entities/user.ts index b75bec757ccc3..5aae21d003ff6 100644 --- a/packages/cli/src/databases/entities/user.ts +++ b/packages/cli/src/databases/entities/user.ts @@ -18,7 +18,7 @@ import { GLOBAL_OWNER_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_ADMIN_SCOPES, -} from '@/permissions/global-roles'; +} from '@/permissions.ee/global-roles'; import { NoUrl } from '@/validators/no-url.validator'; import { NoXss } from '@/validators/no-xss.validator'; diff --git a/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts b/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts index 5ccd0c40a47bb..95a2c11a51f2f 100644 --- a/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts +++ b/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts @@ -1,5 +1,5 @@ import type { MigrationContext, ReversibleMigration } from '@/databases/types'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; export class CreateLdapEntities1674509946020 implements ReversibleMigration { async up({ escape, dbType, isMysql, runQuery }: MigrationContext) { diff --git a/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000000..e2c710428a5e0 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts @@ -0,0 +1,10 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +export class AddProjectIcons1729607673469 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('project', [column('icon').json]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('project', ['icon']); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 2fc39079d42c0..89df27347255a 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const mysqlMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 605c156003b51..d5d72282f4c6f 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const postgresMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000000..f5eb94ffc0068 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts @@ -0,0 +1,5 @@ +import { AddProjectIcons1729607673469 as BaseMigration } from '../common/1729607673469-AddProjectIcons'; + +export class AddProjectIcons1729607673469 extends BaseMigration { + transaction = false as const; +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 0981ece99b343..7fec59baf2ef8 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -39,6 +39,7 @@ import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; +import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons'; import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; @@ -146,6 +147,7 @@ const sqliteMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts new file mode 100644 index 0000000000000..439b806a9afd3 --- /dev/null +++ b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts @@ -0,0 +1,50 @@ +import { mock } from 'jest-mock-extended'; +import { Container } from 'typedi'; + +import { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { mockEntityManager } from '@test/mocking'; + +import { CredentialsRepository } from '../credentials.repository'; + +const entityManager = mockEntityManager(CredentialsEntity); +const repository = Container.get(CredentialsRepository); + +describe('findMany', () => { + const credentialsId = 'cred_123'; + const credential = mock({ id: credentialsId }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ includeData: true, select: { id: true } }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ + includeData: true, + //TODO: fix this + // The function's type does not support this but this is what it + // actually gets from the service because the middlewares are typed + // loosely. + select: ['id'] as never, + }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); +}); diff --git a/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts index afaca2206f129..5f8b441235c2f 100644 --- a/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts @@ -7,7 +7,7 @@ import type { CredentialsEntity } from '@/databases/entities/credentials-entity' import { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { User } from '@/databases/entities/user'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; +import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions.ee/global-roles'; import { mockEntityManager } from '@test/mocking'; describe('SharedCredentialsRepository', () => { diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 4c62e30f637ed..5b88a4ed87e23 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -25,7 +25,10 @@ export class CredentialsRepository extends Repository { }); } - async findMany(listQueryOptions?: ListQuery.Options, credentialIds?: string[]) { + async findMany( + listQueryOptions?: ListQuery.Options & { includeData?: boolean }, + credentialIds?: string[], + ) { const findManyOptions = this.toFindManyOptions(listQueryOptions); if (credentialIds) { @@ -35,13 +38,13 @@ export class CredentialsRepository extends Repository { return await this.find(findManyOptions); } - private toFindManyOptions(listQueryOptions?: ListQuery.Options) { + private toFindManyOptions(listQueryOptions?: ListQuery.Options & { includeData?: boolean }) { const findManyOptions: FindManyOptions = {}; type Select = Array; const defaultRelations = ['shared', 'shared.project']; - const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt']; + const defaultSelect: Select = ['id', 'name', 'type', 'isManaged', 'createdAt', 'updatedAt']; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; @@ -74,6 +77,14 @@ export class CredentialsRepository extends Repository { findManyOptions.relations = defaultRelations; } + if (listQueryOptions.includeData) { + if (Array.isArray(findManyOptions.select)) { + findManyOptions.select.push('data'); + } else { + findManyOptions.select.data = true; + } + } + return findManyOptions; } diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index fbcb7de445463..617dde913681a 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -21,7 +21,7 @@ import { import { DateUtils } from '@n8n/typeorm/util/DateUtils'; import { parse, stringify } from 'flatted'; import pick from 'lodash/pick'; -import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { BinaryDataService, ErrorReporter, Logger } from 'n8n-core'; import { ExecutionCancelledError, ApplicationError } from 'n8n-workflow'; import type { AnnotationVote, @@ -42,7 +42,6 @@ import type { IExecutionFlattedDb, IExecutionResponse, } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { separate } from '@/utils'; import { ExecutionDataRepository } from './execution-data.repository'; diff --git a/packages/cli/src/databases/repositories/settings.repository.ts b/packages/cli/src/databases/repositories/settings.repository.ts index aa4410d6d48fe..a00f52b4e7b4d 100644 --- a/packages/cli/src/databases/repositories/settings.repository.ts +++ b/packages/cli/src/databases/repositories/settings.repository.ts @@ -3,7 +3,7 @@ import { ErrorReporter } from 'n8n-core'; import { Service } from 'typedi'; import config from '@/config'; -import { EXTERNAL_SECRETS_DB_KEY } from '@/external-secrets/constants'; +import { EXTERNAL_SECRETS_DB_KEY } from '@/external-secrets.ee/constants'; import { Settings } from '../entities/settings'; diff --git a/packages/cli/src/databases/subscribers/user-subscriber.ts b/packages/cli/src/databases/subscribers/user-subscriber.ts index 1c55572b14e01..2dc6a1d8e24dd 100644 --- a/packages/cli/src/databases/subscribers/user-subscriber.ts +++ b/packages/cli/src/databases/subscribers/user-subscriber.ts @@ -1,11 +1,9 @@ import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; import { EventSubscriber } from '@n8n/typeorm'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { Container } from 'typedi'; -import { Logger } from '@/logging/logger.service'; - import { Project } from '../entities/project'; import { User } from '../entities/user'; import { UserRepository } from '../repositories/user.repository'; diff --git a/packages/cli/src/databases/types.ts b/packages/cli/src/databases/types.ts index 2bb1802bf248b..dce7d9d24361e 100644 --- a/packages/cli/src/databases/types.ts +++ b/packages/cli/src/databases/types.ts @@ -1,8 +1,7 @@ import type { QueryRunner, ObjectLiteral } from '@n8n/typeorm'; +import type { Logger } from 'n8n-core'; import type { INodeTypes } from 'n8n-workflow'; -import type { Logger } from '@/logging/logger.service'; - import type { createSchemaBuilder } from './dsl'; export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite'; diff --git a/packages/cli/src/databases/utils/migration-helpers.ts b/packages/cli/src/databases/utils/migration-helpers.ts index 1093096f430be..70839b9337873 100644 --- a/packages/cli/src/databases/utils/migration-helpers.ts +++ b/packages/cli/src/databases/utils/migration-helpers.ts @@ -2,14 +2,13 @@ import { GlobalConfig } from '@n8n/config'; import type { ObjectLiteral } from '@n8n/typeorm'; import type { QueryRunner } from '@n8n/typeorm/query-runner/QueryRunner'; import { readFileSync, rmSync } from 'fs'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { Container } from 'typedi'; import { inTest } from '@/constants'; import { createSchemaBuilder } from '@/databases/dsl'; import type { BaseMigration, Migration, MigrationContext, MigrationFn } from '@/databases/types'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; diff --git a/packages/cli/src/decorators/controller.registry.ts b/packages/cli/src/decorators/controller.registry.ts index 3a22090db1469..c68945ca076b6 100644 --- a/packages/cli/src/decorators/controller.registry.ts +++ b/packages/cli/src/decorators/controller.registry.ts @@ -11,7 +11,7 @@ import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import type { BooleanLicenseFeature } from '@/interfaces'; import { License } from '@/license'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { AuthenticatedRequest } from '@/requests'; import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file @@ -93,7 +93,7 @@ export class ControllerRegistry { if (arg.type === 'param') args.push(req.params[arg.key]); else if (['body', 'query'].includes(arg.type)) { const paramType = argTypes[index] as ZodClass; - if (paramType && 'parse' in paramType) { + if (paramType && 'safeParse' in paramType) { const output = paramType.safeParse(req[arg.type]); if (output.success) args.push(output.data); else { diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index bd32add475d6e..8002bbe0940f9 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -1,4 +1,4 @@ -export { Body } from './args'; +export { Body, Query, Param } from './args'; export { RestController } from './rest-controller'; export { Get, Post, Put, Patch, Delete } from './route'; export { Middleware } from './middleware'; diff --git a/packages/cli/src/deprecation/deprecation.service.ts b/packages/cli/src/deprecation/deprecation.service.ts index fef25ff0dd9cb..b3c4cb7d21173 100644 --- a/packages/cli/src/deprecation/deprecation.service.ts +++ b/packages/cli/src/deprecation/deprecation.service.ts @@ -1,8 +1,7 @@ +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; -import { Logger } from '@/logging/logger.service'; - type EnvVarName = string; type Deprecation = { diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts similarity index 100% rename from packages/cli/src/environments/source-control/__tests__/source-control-export.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-git.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts similarity index 100% rename from packages/cli/src/environments/source-control/__tests__/source-control-git.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts similarity index 93% rename from packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts index 508d1eb49a4bd..5ecb04cf8ad33 100644 --- a/packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts @@ -6,7 +6,7 @@ import Container from 'typedi'; import { SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_GIT_FOLDER, -} from '@/environments/source-control/constants'; +} from '@/environments.ee/source-control/constants'; import { generateSshKeyPair, getRepoType, @@ -14,10 +14,10 @@ import { getTrackingInformationFromPrePushResult, getTrackingInformationFromPullResult, sourceControlFoldersExistCheck, -} from '@/environments/source-control/source-control-helper.ee'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import type { SourceControlPreferences } from '@/environments/source-control/types/source-control-preferences'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +} from '@/environments.ee/source-control/source-control-helper.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import type { SourceControlPreferences } from '@/environments.ee/source-control/types/source-control-preferences'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { License } from '@/license'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts similarity index 77% rename from packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index e1ebf0e56aade..8b32062d55a8d 100644 --- a/packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -1,12 +1,13 @@ import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; +import { Container } from 'typedi'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; describe('SourceControlService', () => { const preferencesService = new SourceControlPreferencesService( - new InstanceSettings(mock()), + Container.get(InstanceSettings), mock(), mock(), ); diff --git a/packages/cli/src/environments/source-control/constants.ts b/packages/cli/src/environments.ee/source-control/constants.ts similarity index 100% rename from packages/cli/src/environments/source-control/constants.ts rename to packages/cli/src/environments.ee/source-control/constants.ts diff --git a/packages/cli/src/environments/source-control/middleware/source-control-enabled-middleware.ee.ts b/packages/cli/src/environments.ee/source-control/middleware/source-control-enabled-middleware.ee.ts similarity index 100% rename from packages/cli/src/environments/source-control/middleware/source-control-enabled-middleware.ee.ts rename to packages/cli/src/environments.ee/source-control/middleware/source-control-enabled-middleware.ee.ts diff --git a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts similarity index 98% rename from packages/cli/src/environments/source-control/source-control-export.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index 03352410f435f..cb678d534d41c 100644 --- a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -1,5 +1,5 @@ import { rmSync } from 'fs'; -import { Credentials, InstanceSettings } from 'n8n-core'; +import { Credentials, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type ICredentialDataDecryptedObject } from 'n8n-workflow'; import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises'; import path from 'path'; @@ -11,7 +11,6 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, diff --git a/packages/cli/src/environments/source-control/source-control-git.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control-git.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts index 99571cdd528d0..0d87d4c2d18c5 100644 --- a/packages/cli/src/environments/source-control/source-control-git.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts @@ -1,4 +1,5 @@ import { execSync } from 'child_process'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; import type { @@ -14,7 +15,6 @@ import type { import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { OwnershipService } from '@/services/ownership.service'; import { diff --git a/packages/cli/src/environments/source-control/source-control-helper.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control-helper.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts index 00a9875741eda..6e8b92f09b444 100644 --- a/packages/cli/src/environments/source-control/source-control-helper.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts @@ -1,12 +1,12 @@ import { generateKeyPairSync } from 'crypto'; import { constants as fsConstants, mkdirSync, accessSync } from 'fs'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { ok } from 'node:assert/strict'; import path from 'path'; import { Container } from 'typedi'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { isContainedWithin } from '@/utils/path-util'; import { diff --git a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control-import.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 2e7da80c13312..10ae293b601da 100644 --- a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import glob from 'fast-glob'; -import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core'; +import { Credentials, ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse, ensureError } from 'n8n-workflow'; import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; @@ -23,7 +23,6 @@ import { VariablesRepository } from '@/databases/repositories/variables.reposito import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { IWorkflowToImport } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { isUniqueConstraintError } from '@/response-helper'; import { assertNever } from '@/utils'; diff --git a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts similarity index 98% rename from packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts index 7c061b6c3c5be..ec46e024549aa 100644 --- a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts @@ -1,7 +1,7 @@ import type { ValidationError } from 'class-validator'; import { validate } from 'class-validator'; import { rm as fsRm } from 'fs/promises'; -import { Cipher, InstanceSettings } from 'n8n-core'; +import { Cipher, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { writeFile, chmod, readFile } from 'node:fs/promises'; import path from 'path'; @@ -9,7 +9,6 @@ import Container, { Service } from 'typedi'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_SSH_FOLDER, diff --git a/packages/cli/src/environments/source-control/source-control.controller.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts similarity index 100% rename from packages/cli/src/environments/source-control/source-control.controller.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts diff --git a/packages/cli/src/environments/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index e010210262f62..8fcb1f35711fa 100644 --- a/packages/cli/src/environments/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -1,4 +1,5 @@ import { writeFileSync } from 'fs'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; import type { PushResult } from 'simple-git'; @@ -10,7 +11,6 @@ import type { Variables } from '@/databases/entities/variables'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_DEFAULT_EMAIL, diff --git a/packages/cli/src/environments/source-control/types/export-result.ts b/packages/cli/src/environments.ee/source-control/types/export-result.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/export-result.ts rename to packages/cli/src/environments.ee/source-control/types/export-result.ts diff --git a/packages/cli/src/environments/source-control/types/exportable-credential.ts b/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/exportable-credential.ts rename to packages/cli/src/environments.ee/source-control/types/exportable-credential.ts diff --git a/packages/cli/src/environments/source-control/types/exportable-workflow.ts b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/exportable-workflow.ts rename to packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts diff --git a/packages/cli/src/environments/source-control/types/import-result.ts b/packages/cli/src/environments.ee/source-control/types/import-result.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/import-result.ts rename to packages/cli/src/environments.ee/source-control/types/import-result.ts diff --git a/packages/cli/src/environments/source-control/types/key-pair-type.ts b/packages/cli/src/environments.ee/source-control/types/key-pair-type.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/key-pair-type.ts rename to packages/cli/src/environments.ee/source-control/types/key-pair-type.ts diff --git a/packages/cli/src/environments/source-control/types/key-pair.ts b/packages/cli/src/environments.ee/source-control/types/key-pair.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/key-pair.ts rename to packages/cli/src/environments.ee/source-control/types/key-pair.ts diff --git a/packages/cli/src/environments/source-control/types/requests.ts b/packages/cli/src/environments.ee/source-control/types/requests.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/requests.ts rename to packages/cli/src/environments.ee/source-control/types/requests.ts diff --git a/packages/cli/src/environments/source-control/types/resource-owner.ts b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/resource-owner.ts rename to packages/cli/src/environments.ee/source-control/types/resource-owner.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-commit.ts b/packages/cli/src/environments.ee/source-control/types/source-control-commit.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-commit.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-commit.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-disconnect.ts b/packages/cli/src/environments.ee/source-control/types/source-control-disconnect.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-disconnect.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-disconnect.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-generate-key-pair.ts b/packages/cli/src/environments.ee/source-control/types/source-control-generate-key-pair.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-generate-key-pair.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-generate-key-pair.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-get-status.ts b/packages/cli/src/environments.ee/source-control/types/source-control-get-status.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-get-status.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-get-status.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-preferences.ts b/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-preferences.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-pull-work-folder.ts b/packages/cli/src/environments.ee/source-control/types/source-control-pull-work-folder.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-pull-work-folder.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-pull-work-folder.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-push-work-folder.ts b/packages/cli/src/environments.ee/source-control/types/source-control-push-work-folder.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-push-work-folder.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-push-work-folder.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-push.ts b/packages/cli/src/environments.ee/source-control/types/source-control-push.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-push.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-push.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-set-branch.ts b/packages/cli/src/environments.ee/source-control/types/source-control-set-branch.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-set-branch.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-set-branch.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-set-read-only.ts b/packages/cli/src/environments.ee/source-control/types/source-control-set-read-only.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-set-read-only.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-set-read-only.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-stage.ts b/packages/cli/src/environments.ee/source-control/types/source-control-stage.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-stage.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-stage.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-workflow-version-id.ts b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-workflow-version-id.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts diff --git a/packages/cli/src/environments/source-control/types/source-controlled-file.ts b/packages/cli/src/environments.ee/source-control/types/source-controlled-file.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-controlled-file.ts rename to packages/cli/src/environments.ee/source-control/types/source-controlled-file.ts diff --git a/packages/cli/src/environments/variables/environment-helpers.ts b/packages/cli/src/environments.ee/variables/environment-helpers.ts similarity index 100% rename from packages/cli/src/environments/variables/environment-helpers.ts rename to packages/cli/src/environments.ee/variables/environment-helpers.ts diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts similarity index 94% rename from packages/cli/src/environments/variables/variables.controller.ee.ts rename to packages/cli/src/environments.ee/variables/variables.controller.ee.ts index a38906b800c26..460d5fa0093ed 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts @@ -1,7 +1,15 @@ import { VariableListRequestDto } from '@n8n/api-types'; -import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators'; -import { Query } from '@/decorators/args'; +import { + Delete, + Get, + GlobalScope, + Licensed, + Patch, + Post, + Query, + RestController, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts similarity index 100% rename from packages/cli/src/environments/variables/variables.service.ee.ts rename to packages/cli/src/environments.ee/variables/variables.service.ee.ts diff --git a/packages/cli/src/errors/feature-not-licensed.error.ts b/packages/cli/src/errors/feature-not-licensed.error.ts index a61015f2e419f..aa53655154fa4 100644 --- a/packages/cli/src/errors/feature-not-licensed.error.ts +++ b/packages/cli/src/errors/feature-not-licensed.error.ts @@ -6,6 +6,7 @@ export class FeatureNotLicensedError extends ApplicationError { constructor(feature: (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]) { super( `Your license does not allow for ${feature}. To enable ${feature}, please upgrade to a license that supports this feature.`, + { level: 'warning' }, ); } } diff --git a/packages/cli/src/evaluation/metric.schema.ts b/packages/cli/src/evaluation.ee/metric.schema.ts similarity index 100% rename from packages/cli/src/evaluation/metric.schema.ts rename to packages/cli/src/evaluation.ee/metric.schema.ts diff --git a/packages/cli/src/evaluation/metrics.controller.ts b/packages/cli/src/evaluation.ee/metrics.controller.ts similarity index 99% rename from packages/cli/src/evaluation/metrics.controller.ts rename to packages/cli/src/evaluation.ee/metrics.controller.ts index 816228bf13494..2072b978b1ba9 100644 --- a/packages/cli/src/evaluation/metrics.controller.ts +++ b/packages/cli/src/evaluation.ee/metrics.controller.ts @@ -6,7 +6,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { testMetricCreateRequestBodySchema, testMetricPatchRequestBodySchema, -} from '@/evaluation/metric.schema'; +} from '@/evaluation.ee/metric.schema'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; import { TestDefinitionService } from './test-definition.service.ee'; diff --git a/packages/cli/src/evaluation/test-definition.schema.ts b/packages/cli/src/evaluation.ee/test-definition.schema.ts similarity index 100% rename from packages/cli/src/evaluation/test-definition.schema.ts rename to packages/cli/src/evaluation.ee/test-definition.schema.ts diff --git a/packages/cli/src/evaluation/test-definition.service.ee.ts b/packages/cli/src/evaluation.ee/test-definition.service.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-definition.service.ee.ts rename to packages/cli/src/evaluation.ee/test-definition.service.ee.ts diff --git a/packages/cli/src/evaluation/test-definitions.controller.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts similarity index 97% rename from packages/cli/src/evaluation/test-definitions.controller.ee.ts rename to packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts index ef4a3ed4613a6..bd4a841948e73 100644 --- a/packages/cli/src/evaluation/test-definitions.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts @@ -7,8 +7,8 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { testDefinitionCreateRequestBodySchema, testDefinitionPatchRequestBodySchema, -} from '@/evaluation/test-definition.schema'; -import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee'; +} from '@/evaluation.ee/test-definition.schema'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; diff --git a/packages/cli/src/evaluation/test-definitions.types.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-definitions.types.ee.ts rename to packages/cli/src/evaluation.ee/test-definitions.types.ee.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/create-pin-data.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/create-pin-data.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/evaluation-metrics.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/evaluation-metrics.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/get-start-node.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/get-start-node.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/get-start-node.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/get-start-node.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.multiple-triggers.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.multiple-triggers.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.multiple-triggers.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.multiple-triggers.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.under-test.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.under-test.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.under-test.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.under-test.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/evaluation-metrics.ee.ts b/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/evaluation-metrics.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts diff --git a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts diff --git a/packages/cli/src/evaluation/test-runner/utils.ee.ts b/packages/cli/src/evaluation.ee/test-runner/utils.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/utils.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/utils.ee.ts diff --git a/packages/cli/src/evaluation/test-runs.controller.ee.ts b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts similarity index 96% rename from packages/cli/src/evaluation/test-runs.controller.ee.ts rename to packages/cli/src/evaluation.ee/test-runs.controller.ee.ts index 744c420fc0d9a..aae71376e4857 100644 --- a/packages/cli/src/evaluation/test-runs.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts @@ -1,7 +1,7 @@ import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { Delete, Get, RestController } from '@/decorators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { TestRunsRequest } from '@/evaluation/test-definitions.types.ee'; +import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts index 4046855f30e0a..90049da1fff6a 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts @@ -1,8 +1,8 @@ +import { Logger } from 'n8n-core'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import { Container } from 'typedi'; import type { EventDestinations } from '@/databases/entities/event-destinations'; -import { Logger } from '@/logging/logger.service'; import { MessageEventBusDestinationSentry } from './message-event-bus-destination-sentry.ee'; import { MessageEventBusDestinationSyslog } from './message-event-bus-destination-syslog.ee'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts index 83db469d7913c..c0e7657e0fa95 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Logger } from 'n8n-core'; import type { MessageEventBusDestinationOptions, MessageEventBusDestinationSyslogOptions, @@ -7,8 +8,6 @@ import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import syslog from 'syslog-client'; import Container from 'typedi'; -import { Logger } from '@/logging/logger.service'; - import { MessageEventBusDestination } from './message-event-bus-destination.ee'; import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic'; import type { MessageEventBus, MessageWithCallback } from '../message-event-bus/message-event-bus'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts index a5373d0cc5cd1..8bc967e789266 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts @@ -14,7 +14,7 @@ import type { import Container from 'typedi'; import { CredentialsHelper } from '@/credentials-helper'; -import * as SecretsHelpers from '@/external-secrets/external-secrets-helper.ee'; +import * as SecretsHelpers from '@/external-secrets.ee/external-secrets-helper.ee'; import { MessageEventBusDestination } from './message-event-bus-destination.ee'; import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts index 7b65767b04ea7..c3aaf71173513 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import type { INodeCredentials, MessageEventBusDestinationOptions } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import { Container } from 'typedi'; @@ -5,7 +6,6 @@ import { v4 as uuid } from 'uuid'; import { EventDestinationsRepository } from '@/databases/repositories/event-destinations.repository'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import type { EventMessageTypes } from '../event-message-classes'; import type { AbstractEventMessage } from '../event-message-classes/abstract-event-message'; diff --git a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts index 3f3cb50b185dd..c418035a70a2b 100644 --- a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts +++ b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts @@ -4,7 +4,7 @@ import { GlobalConfig } from '@n8n/config'; import { once as eventOnce } from 'events'; import { createReadStream, existsSync, rmSync } from 'fs'; import remove from 'lodash/remove'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { EventMessageTypeNames, jsonParse } from 'n8n-workflow'; import path, { parse } from 'path'; import readline from 'readline'; @@ -12,7 +12,6 @@ import Container from 'typedi'; import { Worker } from 'worker_threads'; import { inTest } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import type { EventMessageTypes } from '../event-message-classes'; import { isEventMessageOptions } from '../event-message-classes/abstract-event-message'; diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 3cf5a5a5d0572..2cd35a596f811 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -5,6 +5,7 @@ import type { DeleteResult } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import EventEmitter from 'events'; import uniqby from 'lodash/uniqBy'; +import { Logger } from 'n8n-core'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -13,7 +14,6 @@ import { EventDestinationsRepository } from '@/databases/repositories/event-dest import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts index eedbf27c9e6eb..ac52cf3920228 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts @@ -1,11 +1,11 @@ import { ErrorReporter } from 'n8n-core'; +import { Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress'; import * as fnModule from '@/execution-lifecycle-hooks/to-save-settings'; import type { IExecutionResponse } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { mockInstance } from '@test/mocking'; mockInstance(Logger); diff --git a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts b/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts index d9a1a9a0e99c1..15ac8b905cb4c 100644 --- a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts +++ b/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts @@ -1,10 +1,9 @@ -import { BinaryDataService } from 'n8n-core'; import type { BinaryData } from 'n8n-core'; +import { BinaryDataService, Logger } from 'n8n-core'; import type { IRun, WorkflowExecuteMode } from 'n8n-workflow'; import Container from 'typedi'; import config from '@/config'; -import { Logger } from '@/logging/logger.service'; /** * Whenever the execution ID is not available to the binary data service at the diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts index c1de2646c0269..2047c9e82eade 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts @@ -1,10 +1,9 @@ -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { Container } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; -import { Logger } from '@/logging/logger.service'; export async function saveExecutionProgress( workflowData: IWorkflowBase, diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts index 68fd528f14110..4c91222126cae 100644 --- a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts +++ b/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts @@ -1,10 +1,10 @@ import pick from 'lodash/pick'; +import { Logger } from 'n8n-core'; import { ensureError, type ExecutionStatus, type IRun, type IWorkflowBase } from 'n8n-workflow'; import { Container } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IExecutionDb, UpdateExecutionPayload } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { ExecutionMetadataService } from '@/services/execution-metadata.service'; import { isWorkflowIdValid } from '@/utils'; diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index 115a1a52f60e8..0e6017a2bd395 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -22,7 +22,7 @@ import { setupMessages } from './utils'; describe('ExecutionRecoveryService', () => { const push = mockInstance(Push); - const instanceSettings = new InstanceSettings(mock()); + const instanceSettings = Container.get(InstanceSettings); let executionRecoveryService: ExecutionRecoveryService; let executionRepository: ExecutionRepository; diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index b91836578c853..e425c1e588eeb 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -183,7 +183,7 @@ describe('ExecutionService', () => { describe('scaling mode', () => { describe('manual execution', () => { - it('should delegate to regular mode in scaling mode', async () => { + it('should stop a `running` execution in scaling mode', async () => { /** * Arrange */ @@ -197,6 +197,8 @@ describe('ExecutionService', () => { concurrencyControl.has.mockReturnValue(false); activeExecutions.has.mockReturnValue(true); waitTracker.has.mockReturnValue(false); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); // @ts-expect-error Private method const stopInRegularModeSpy = jest.spyOn(executionService, 'stopInRegularMode'); @@ -209,7 +211,7 @@ describe('ExecutionService', () => { /** * Assert */ - expect(stopInRegularModeSpy).toHaveBeenCalledWith(execution); + expect(stopInRegularModeSpy).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); expect(executionRepository.stopDuringRun).toHaveBeenCalledWith(execution); @@ -242,8 +244,8 @@ describe('ExecutionService', () => { */ expect(waitTracker.stopExecution).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(scalingService.findJobsByStatus).toHaveBeenCalled(); - expect(scalingService.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); @@ -268,8 +270,8 @@ describe('ExecutionService', () => { * Assert */ expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - expect(scalingService.findJobsByStatus).toHaveBeenCalled(); - expect(scalingService.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index a10fc995a472d..f307ce0677f5c 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -1,5 +1,5 @@ import type { DateTime } from 'luxon'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import type { IRun, ITaskData } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -10,7 +10,6 @@ import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventService } from '@/events/event.service'; import type { IExecutionResponse } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { Push } from '@/push'; import { getWorkflowHooksMain } from '@/workflow-execute-additional-data'; // @TODO: Dependency cycle diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 433955254f698..9eba37773fcbc 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,5 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { validate as jsonSchemaValidate } from 'jsonschema'; +import { Logger } from 'n8n-core'; import type { ExecutionError, ExecutionStatus, @@ -15,7 +16,7 @@ import { Workflow, WorkflowOperationError, } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; @@ -38,7 +39,6 @@ import type { IWorkflowDb, } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { WaitTracker } from '@/wait-tracker'; import { WorkflowRunner } from '@/workflow-runner'; @@ -464,11 +464,6 @@ export class ExecutionService { } 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)) { this.activeExecutions.stopExecution(execution.id); } @@ -477,18 +472,6 @@ export class ExecutionService { this.waitTracker.stopExecution(execution.id); } - const { ScalingService } = await import('@/scaling/scaling.service'); - const scalingService = Container.get(ScalingService); - const jobs = await scalingService.findJobsByStatus(['active', 'waiting']); - - const job = jobs.find(({ data }) => data.executionId === execution.id); - - if (job) { - await scalingService.stopJob(job); - } else { - this.logger.debug('Job to stop not in queue', { executionId: execution.id }); - } - return await this.executionRepository.stopDuringRun(execution); } diff --git a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts b/packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts similarity index 95% rename from packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts rename to packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts index 05eabd104fa52..341558a5fe00f 100644 --- a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts +++ b/packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts @@ -3,8 +3,8 @@ import { Cipher } from 'n8n-core'; import { Container } from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; -import { ExternalSecretsProviders } from '@/external-secrets/external-secrets-providers.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; +import { ExternalSecretsProviders } from '@/external-secrets.ee/external-secrets-providers.ee'; import type { ExternalSecretsSettings } from '@/interfaces'; import { License } from '@/license'; import { diff --git a/packages/cli/src/external-secrets/constants.ts b/packages/cli/src/external-secrets.ee/constants.ts similarity index 100% rename from packages/cli/src/external-secrets/constants.ts rename to packages/cli/src/external-secrets.ee/constants.ts diff --git a/packages/cli/src/external-secrets/external-secrets-helper.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-helper.ee.ts similarity index 100% rename from packages/cli/src/external-secrets/external-secrets-helper.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-helper.ee.ts diff --git a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts similarity index 99% rename from packages/cli/src/external-secrets/external-secrets-manager.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts index 2de681a7d62be..d4adf17255554 100644 --- a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts +++ b/packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts @@ -1,4 +1,4 @@ -import { Cipher } from 'n8n-core'; +import { Cipher, Logger } from 'n8n-core'; import { jsonParse, type IDataObject, ApplicationError, ensureError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -10,7 +10,6 @@ import type { SecretsProviderSettings, } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants'; diff --git a/packages/cli/src/external-secrets/external-secrets-providers.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-providers.ee.ts similarity index 100% rename from packages/cli/src/external-secrets/external-secrets-providers.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-providers.ee.ts diff --git a/packages/cli/src/external-secrets/external-secrets.controller.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets.controller.ee.ts similarity index 100% rename from packages/cli/src/external-secrets/external-secrets.controller.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets.controller.ee.ts diff --git a/packages/cli/src/external-secrets/external-secrets.service.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets.service.ee.ts similarity index 100% rename from packages/cli/src/external-secrets/external-secrets.service.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets.service.ee.ts diff --git a/packages/cli/src/external-secrets/providers/__tests__/azure-key-vault.test.ts b/packages/cli/src/external-secrets.ee/providers/__tests__/azure-key-vault.test.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/__tests__/azure-key-vault.test.ts rename to packages/cli/src/external-secrets.ee/providers/__tests__/azure-key-vault.test.ts diff --git a/packages/cli/src/external-secrets/providers/__tests__/gcp-secrets-manager.test.ts b/packages/cli/src/external-secrets.ee/providers/__tests__/gcp-secrets-manager.test.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/__tests__/gcp-secrets-manager.test.ts rename to packages/cli/src/external-secrets.ee/providers/__tests__/gcp-secrets-manager.test.ts diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-client.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-client.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-client.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-client.ts diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts similarity index 97% rename from packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts index 6c2c0669fb79a..b22c2f24364a4 100644 --- a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts +++ b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts @@ -1,10 +1,10 @@ +import { Logger } from 'n8n-core'; import type { INodeProperties } from 'n8n-workflow'; import Container from 'typedi'; import { UnknownAuthTypeError } from '@/errors/unknown-auth-type.error'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { AwsSecretsClient } from './aws-secrets-client'; import type { AwsSecretsManagerContext } from './types'; diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/types.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/aws-secrets/types.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/types.ts diff --git a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts similarity index 98% rename from packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts rename to packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts index 7961f21bad24b..a03ef468b6b04 100644 --- a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts +++ b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts @@ -1,11 +1,11 @@ import type { SecretClient } from '@azure/keyvault-secrets'; +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow'; import Container from 'typedi'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { AzureKeyVaultContext } from './types'; diff --git a/packages/cli/src/external-secrets/providers/azure-key-vault/types.ts b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/azure-key-vault/types.ts rename to packages/cli/src/external-secrets.ee/providers/azure-key-vault/types.ts diff --git a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts similarity index 98% rename from packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts rename to packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts index c4bf71cb72169..fe039fd50ae6f 100644 --- a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts +++ b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts @@ -1,10 +1,10 @@ import type { SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager'; +import { Logger } from 'n8n-core'; import { ensureError, jsonParse, type INodeProperties } from 'n8n-workflow'; import Container from 'typedi'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { GcpSecretsManagerContext, diff --git a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/types.ts b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/gcp-secrets-manager/types.ts rename to packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/types.ts diff --git a/packages/cli/src/external-secrets/providers/infisical.ts b/packages/cli/src/external-secrets.ee/providers/infisical.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/infisical.ts rename to packages/cli/src/external-secrets.ee/providers/infisical.ts diff --git a/packages/cli/src/external-secrets/providers/vault.ts b/packages/cli/src/external-secrets.ee/providers/vault.ts similarity index 99% rename from packages/cli/src/external-secrets/providers/vault.ts rename to packages/cli/src/external-secrets.ee/providers/vault.ts index 0f1e93a5da5cf..8030832376fe9 100644 --- a/packages/cli/src/external-secrets/providers/vault.ts +++ b/packages/cli/src/external-secrets.ee/providers/vault.ts @@ -1,11 +1,11 @@ import type { AxiosInstance, AxiosResponse } from 'axios'; import axios from 'axios'; +import { Logger } from 'n8n-core'; import type { IDataObject, INodeProperties } from 'n8n-workflow'; import { Container } from 'typedi'; import type { SecretsProviderSettings, SecretsProviderState } from '@/interfaces'; import { SecretsProvider } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '../constants'; import { preferGet } from '../external-secrets-helper.ee'; diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8581483577402..168b9c907933e 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -1,6 +1,6 @@ import { Help } from '@oclif/core'; -import Container from 'typedi'; -import { Logger } from 'winston'; +import { Logger } from 'n8n-core'; +import { Container } from 'typedi'; // oclif expects a default export // eslint-disable-next-line import/no-default-export diff --git a/packages/cli/src/ldap/__tests__/helpers.test.ts b/packages/cli/src/ldap.ee/__tests__/helpers.test.ts similarity index 96% rename from packages/cli/src/ldap/__tests__/helpers.test.ts rename to packages/cli/src/ldap.ee/__tests__/helpers.test.ts index 5d38c58e1a521..3e7a9c4b4b72a 100644 --- a/packages/cli/src/ldap/__tests__/helpers.test.ts +++ b/packages/cli/src/ldap.ee/__tests__/helpers.test.ts @@ -2,7 +2,7 @@ import { AuthIdentity } from '@/databases/entities/auth-identity'; import { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import * as helpers from '@/ldap/helpers.ee'; +import * as helpers from '@/ldap.ee/helpers.ee'; import { mockInstance } from '@test/mocking'; const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/src/ldap/constants.ts b/packages/cli/src/ldap.ee/constants.ts similarity index 100% rename from packages/cli/src/ldap/constants.ts rename to packages/cli/src/ldap.ee/constants.ts diff --git a/packages/cli/src/ldap/helpers.ee.ts b/packages/cli/src/ldap.ee/helpers.ee.ts similarity index 100% rename from packages/cli/src/ldap/helpers.ee.ts rename to packages/cli/src/ldap.ee/helpers.ee.ts diff --git a/packages/cli/src/ldap/ldap.controller.ee.ts b/packages/cli/src/ldap.ee/ldap.controller.ee.ts similarity index 100% rename from packages/cli/src/ldap/ldap.controller.ee.ts rename to packages/cli/src/ldap.ee/ldap.controller.ee.ts diff --git a/packages/cli/src/ldap/ldap.service.ee.ts b/packages/cli/src/ldap.ee/ldap.service.ee.ts similarity index 99% rename from packages/cli/src/ldap/ldap.service.ee.ts rename to packages/cli/src/ldap.ee/ldap.service.ee.ts index b552db697401a..9a794bd5fc8f4 100644 --- a/packages/cli/src/ldap/ldap.service.ee.ts +++ b/packages/cli/src/ldap.ee/ldap.service.ee.ts @@ -2,7 +2,7 @@ import { QueryFailedError } from '@n8n/typeorm'; import type { Entry as LdapUser, ClientOptions } from 'ldapts'; import { Client } from 'ldapts'; -import { Cipher } from 'n8n-core'; +import { Cipher, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import type { ConnectionOptions } from 'tls'; import { Service } from 'typedi'; @@ -14,13 +14,12 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, setCurrentAuthenticationMethod, -} from '@/sso/sso-helpers'; +} from '@/sso.ee/sso-helpers'; import { BINARY_AD_ATTRIBUTES, diff --git a/packages/cli/src/ldap/types.ts b/packages/cli/src/ldap.ee/types.ts similarity index 100% rename from packages/cli/src/ldap/types.ts rename to packages/cli/src/ldap.ee/types.ts diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 2a3ae6fd6dee6..ded98d3f3c01c 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -1,13 +1,12 @@ import { GlobalConfig } from '@n8n/config'; import type { TEntitlement, TFeatures, TLicenseBlock } from '@n8n_io/license-sdk'; import { LicenseManager } from '@n8n_io/license-sdk'; -import { InstanceSettings, ObjectStoreService } from 'n8n-core'; +import { InstanceSettings, ObjectStoreService, Logger } from 'n8n-core'; import Container, { Service } from 'typedi'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { LicenseMetricsService } from '@/metrics/license-metrics.service'; import { @@ -256,6 +255,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ASK_AI); } + isAiCreditsEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.AI_CREDITS); + } + isAdvancedExecutionFiltersEnabled() { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } @@ -366,6 +369,10 @@ export class License { return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } + getAiCredits() { + return this.getFeatureValue(LICENSE_QUOTAS.AI_CREDITS) ?? 0; + } + getWorkflowHistoryPruneLimit() { return ( this.getFeatureValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 1419d58b83572..750b42b432381 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from 'axios'; +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -7,7 +8,6 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; type LicenseError = Error & { errorId?: keyof typeof LicenseErrors }; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index db62e8415ea71..e462bd4157861 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -11,6 +11,7 @@ import { LazyPackageDirectoryLoader, UnrecognizedCredentialTypeError, UnrecognizedNodeTypeError, + Logger, } from 'n8n-core'; import type { KnownNodesAndCredentials, @@ -36,7 +37,6 @@ import { CLI_DIR, inE2ETests, } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { isContainedWithin } from '@/utils/path-util'; interface LoadedNodesAndCredentials { diff --git a/packages/cli/src/logging/constants.ts b/packages/cli/src/logging/constants.ts deleted file mode 100644 index 107327694b1b9..0000000000000 --- a/packages/cli/src/logging/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const noOp = () => {}; - -export const LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'silent'] as const; diff --git a/packages/cli/src/logging/types.ts b/packages/cli/src/logging/types.ts deleted file mode 100644 index bb01834326090..0000000000000 --- a/packages/cli/src/logging/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { LogScope } from '@n8n/config'; - -import type { LOG_LEVELS } from './constants'; - -export type LogLevel = (typeof LOG_LEVELS)[number]; - -export type LogMetadata = { - [key: string]: unknown; - scopes?: LogScope[]; - file?: string; - function?: string; -}; - -export type LogLocationMetadata = Pick; diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 65174d20b50f8..c6749696feb6b 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -4,6 +4,7 @@ import { filterDisabledNodes, recreateNodeExecutionStack, WorkflowExecute, + Logger, } from 'n8n-core'; import type { IPinData, @@ -16,8 +17,6 @@ import type { import type PCancelable from 'p-cancelable'; import { Service } from 'typedi'; -import { Logger } from '@/logging/logger.service'; - @Service() export class ManualExecutionService { constructor(private readonly logger: Logger) {} diff --git a/packages/cli/src/permissions/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts similarity index 100% rename from packages/cli/src/permissions/check-access.ts rename to packages/cli/src/permissions.ee/check-access.ts diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions.ee/global-roles.ts similarity index 100% rename from packages/cli/src/permissions/global-roles.ts rename to packages/cli/src/permissions.ee/global-roles.ts diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts similarity index 100% rename from packages/cli/src/permissions/project-roles.ts rename to packages/cli/src/permissions.ee/project-roles.ts diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions.ee/resource-roles.ts similarity index 100% rename from packages/cli/src/permissions/resource-roles.ts rename to packages/cli/src/permissions.ee/resource-roles.ts diff --git a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts index fdcb2f16baabf..6f960980ca715 100644 --- a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts @@ -5,10 +5,10 @@ import { Container } from 'typedi'; import { getTrackingInformationFromPullResult, isSourceControlLicensed, -} from '@/environments/source-control/source-control-helper.ee'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; -import type { ImportResult } from '@/environments/source-control/types/import-result'; +} from '@/environments.ee/source-control/source-control-helper.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import type { ImportResult } from '@/environments.ee/source-control/types/import-result'; import { EventService } from '@/events/event.service'; import type { PublicSourceControlRequest } from '../../../types'; diff --git a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts index 3b84b89da31cc..e6c36be3ce421 100644 --- a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts @@ -1,4 +1,4 @@ -import { RoleChangeRequestDto } from '@n8n/api-types'; +import { InviteUsersRequestDto, RoleChangeRequestDto } from '@n8n/api-types'; import type express from 'express'; import type { Response } from 'express'; import { Container } from 'typedi'; @@ -18,7 +18,7 @@ import { } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; -type Create = UserRequest.Invite; +type Create = AuthenticatedRequest<{}, {}, InviteUsersRequestDto>; type Delete = UserRequest.Delete; type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto, {}>; @@ -82,8 +82,16 @@ export = { createUser: [ globalScope('user:create'), async (req: Create, res: Response) => { - const usersInvited = await Container.get(InvitationController).inviteUser(req); + const { data, error } = InviteUsersRequestDto.safeParse(req.body); + if (error) { + return res.status(400).json(error.errors[0]); + } + const usersInvited = await Container.get(InvitationController).inviteUser( + req, + res, + data as InviteUsersRequestDto, + ); return res.status(201).json(usersInvited); }, ], diff --git a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts index 65fb1daab5653..9e5b6dabe6dee 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts @@ -2,7 +2,7 @@ import type { Response } from 'express'; import Container from 'typedi'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; -import { VariablesController } from '@/environments/variables/variables.controller.ee'; +import { VariablesController } from '@/environments.ee/variables/variables.controller.ee'; import type { PaginatedRequest } from '@/public-api/types'; import type { VariablesRequest } from '@/requests'; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 7a9003dc284c6..500eaf0d13fa1 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -17,7 +17,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { WorkflowService } from '@/workflows/workflow.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index ed68d4761c8d4..ace75ef6102fb 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -6,7 +6,7 @@ import { Container } from 'typedi'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { BooleanLicenseFeature } from '@/interfaces'; import { License } from '@/license'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { AuthenticatedRequest } from '@/requests'; import type { PaginatedRequest } from '../../../types'; diff --git a/packages/cli/src/push/__tests__/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts index 03457926b13a5..ae46487a38dec 100644 --- a/packages/cli/src/push/__tests__/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -20,7 +20,7 @@ describe('Push', () => { test('should validate pushRef on requests for websocket backend', () => { config.set('push.backend', 'websocket'); - const push = new Push(mock(), mock()); + const push = new Push(mock(), mock(), mock()); const ws = mock(); const request = mock({ user, ws }); request.query = { pushRef: '' }; @@ -33,7 +33,7 @@ describe('Push', () => { test('should validate pushRef on requests for SSE backend', () => { config.set('push.backend', 'sse'); - const push = new Push(mock(), mock()); + const push = new Push(mock(), mock(), mock()); const request = mock({ user, ws: undefined }); request.query = { pushRef: '' }; expect(() => push.handleRequest(request, mock())).toThrow(BadRequestError); diff --git a/packages/cli/src/push/__tests__/websocket.push.test.ts b/packages/cli/src/push/__tests__/websocket.push.test.ts index fd1e2f27a0b52..c13a319ebf27a 100644 --- a/packages/cli/src/push/__tests__/websocket.push.test.ts +++ b/packages/cli/src/push/__tests__/websocket.push.test.ts @@ -1,10 +1,10 @@ import type { PushMessage } from '@n8n/api-types'; import { EventEmitter } from 'events'; +import { Logger } from 'n8n-core'; import { Container } from 'typedi'; import type WebSocket from 'ws'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { WebSocketPush } from '@/push/websocket.push'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 574f8a0def056..b1c4514d8d6f6 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -1,10 +1,9 @@ import type { PushMessage } from '@n8n/api-types'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { assert, jsonStringify } from 'n8n-workflow'; import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import type { OnPushMessage } from '@/push/types'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 7325981d0bba5..20d0c7a9c5d0b 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -2,7 +2,8 @@ import type { PushMessage } from '@n8n/api-types'; import type { Application } from 'express'; import { ServerResponse } 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 { Container, Service } from 'typedi'; import { parse as parseUrl } from 'url'; @@ -10,6 +11,7 @@ import { Server as WSServer } from 'ws'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; +import { TRIMMED_TASK_DATA_CONNECTIONS } from '@/constants'; import type { User } from '@/databases/entities/user'; import { OnShutdown } from '@/decorators/on-shutdown'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -27,6 +29,12 @@ type PushEvents = { 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. * Uses either server-sent events (SSE, unidirectional from backend --> frontend) @@ -43,8 +51,10 @@ export class Push extends TypedEmitter { constructor( private readonly instanceSettings: InstanceSettings, private readonly publisher: Publisher, + private readonly logger: Logger, ) { super(); + this.logger = this.logger.scoped('push'); if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg)); } @@ -85,18 +95,14 @@ export class Push extends TypedEmitter { 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) { - /** - * Multi-main setup: In a manual webhook execution, the main process that - * 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 }, - }); + if (this.shouldRelayViaPubSub(pushRef)) { + this.relayViaPubSub(pushMsg, pushRef); return; } @@ -111,6 +117,66 @@ export class Push extends TypedEmitter { onShutdown() { 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) => { diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7afb1e1bd3c7b..57765495666df 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,26 +1,20 @@ import type { Scope } from '@n8n/permissions'; -import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import type express from 'express'; import type { - BannerName, ICredentialDataDecryptedObject, IDataObject, - ILoadOptions, INodeCredentialTestRequest, - INodeCredentials, - INodeParameters, - INodeTypeNameVersion, IPersonalizationSurveyAnswersV4, IUser, } from 'n8n-workflow'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; -import type { Project, ProjectType } from '@/databases/entities/project'; +import type { Project, ProjectIcon, ProjectType } from '@/databases/entities/project'; import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowHistory } from '@/databases/entities/workflow-history'; -import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/interfaces'; +import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; import type { ProjectRole } from './databases/entities/project-relation'; import type { ScopesField } from './services/role.service'; @@ -125,7 +119,7 @@ export namespace ListQuery { } type SlimUser = Pick; -export type SlimProject = Pick; +export type SlimProject = Pick; export function hasSharing( workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], @@ -144,6 +138,7 @@ export declare namespace CredentialRequest { type: string; data: ICredentialDataDecryptedObject; projectId?: string; + isManaged?: boolean; }>; type Create = AuthenticatedRequest<{}, {}, CredentialProperties>; @@ -196,53 +191,11 @@ export declare namespace MeRequest { export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; } -export interface UserSetupPayload { - email: string; - password: string; - firstName: string; - lastName: string; - mfaEnabled?: boolean; - mfaSecret?: string; - mfaRecoveryCodes?: string[]; -} - -// ---------------------------------- -// /owner -// ---------------------------------- - -export declare namespace OwnerRequest { - type Post = AuthenticatedRequest<{}, {}, UserSetupPayload, {}>; - - type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: BannerName }>, {}>; -} - -// ---------------------------------- -// password reset endpoints -// ---------------------------------- - -export declare namespace PasswordResetRequest { - export type Email = AuthlessRequest<{}, {}, Pick>; - - export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>; - - export type NewPassword = AuthlessRequest< - {}, - {}, - Pick & { token?: string; userId?: string; mfaCode?: string } - >; -} - // ---------------------------------- // /users // ---------------------------------- export declare namespace UserRequest { - export type Invite = AuthenticatedRequest< - {}, - {}, - Array<{ email: string; role?: AssignableRole }> - >; - export type InviteResponse = { user: { id: string; @@ -254,18 +207,6 @@ export declare namespace UserRequest { error?: string; }; - export type ResolveSignUp = AuthlessRequest< - {}, - {}, - {}, - { inviterId?: string; inviteeId?: string } - >; - - export type SignUp = AuthenticatedRequest< - { id: string }, - { inviterId?: string; inviteeId?: string } - >; - export type Delete = AuthenticatedRequest< { id: string; email: string; identifier: string }, {}, @@ -281,36 +222,8 @@ export declare namespace UserRequest { >; export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; - - export type Reinvite = AuthenticatedRequest<{ id: string }>; - - export type Update = AuthlessRequest< - { id: string }, - {}, - { - inviterId: string; - firstName: string; - lastName: string; - password: string; - } - >; } -// ---------------------------------- -// /login -// ---------------------------------- - -export type LoginRequest = AuthlessRequest< - {}, - {}, - { - email: string; - password: string; - mfaCode?: string; - mfaRecoveryCode?: string; - } ->; - // ---------------------------------- // MFA endpoints // ---------------------------------- @@ -351,47 +264,6 @@ export declare namespace OAuthRequest { } } -// ---------------------------------- -// /dynamic-node-parameters -// ---------------------------------- -export declare namespace DynamicNodeParametersRequest { - type BaseRequest = AuthenticatedRequest< - {}, - {}, - { - path: string; - nodeTypeAndVersion: INodeTypeNameVersion; - currentNodeParameters: INodeParameters; - methodName?: string; - credentials?: INodeCredentials; - } & RequestBody, - {} - >; - - /** POST /dynamic-node-parameters/options */ - type Options = BaseRequest<{ - loadOptions?: ILoadOptions; - }>; - - /** POST /dynamic-node-parameters/resource-locator-results */ - type ResourceLocatorResults = BaseRequest<{ - methodName: string; - filter?: string; - paginationToken?: string; - }>; - - /** POST dynamic-node-parameters/resource-mapper-fields */ - type ResourceMapperFields = BaseRequest<{ - methodName: string; - }>; - - /** POST /dynamic-node-parameters/action-result */ - type ActionResult = BaseRequest<{ - handler: string; - payload: IDataObject | string | undefined; - }>; -} - // ---------------------------------- // /tags // ---------------------------------- @@ -523,6 +395,7 @@ export declare namespace ProjectRequest { Project, { name: string; + icon?: ProjectIcon; } >; @@ -551,6 +424,7 @@ export declare namespace ProjectRequest { type ProjectWithRelations = { id: string; name: string | undefined; + icon: ProjectIcon; type: ProjectType; relations: ProjectRelationResponse[]; scopes: Scope[]; @@ -560,7 +434,11 @@ export declare namespace ProjectRequest { type Update = AuthenticatedRequest< { projectId: string }, {}, - { name?: string; relations?: ProjectRelationPayload[] } + { + name?: string; + relations?: ProjectRelationPayload[]; + icon?: { type: 'icon' | 'emoji'; value: string }; + } >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; } @@ -574,15 +452,3 @@ export declare namespace NpsSurveyRequest { // once some schema validation is added type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>; } - -// ---------------------------------- -// /ai-assistant -// ---------------------------------- - -export declare namespace AiAssistantRequest { - type Chat = AuthenticatedRequest<{}, {}, AiAssistantSDK.ChatRequestPayload>; - - type SuggestionPayload = { sessionId: string; suggestionId: string }; - type ApplySuggestionPayload = AuthenticatedRequest<{}, {}, SuggestionPayload>; - type AskAiPayload = AuthenticatedRequest<{}, {}, AiAssistantSDK.AskAiRequestPayload>; -} diff --git a/packages/cli/src/response-helper.ts b/packages/cli/src/response-helper.ts index 0e70aa312f72c..f7f448cc9dc6f 100644 --- a/packages/cli/src/response-helper.ts +++ b/packages/cli/src/response-helper.ts @@ -1,13 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { Request, Response } from 'express'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { FORM_TRIGGER_PATH_IDENTIFIER, NodeApiError } from 'n8n-workflow'; import { Readable } from 'node:stream'; import picocolors from 'picocolors'; import Container from 'typedi'; import { inDevelopment } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { ResponseError } from './errors/response-errors/abstract/response.error'; diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index 73264e638269e..897986c915c46 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -19,6 +19,7 @@ describe('JobProcessor', () => { mock(), mock(), mock(), + mock(), ); const result = await jobProcessor.processJob(mock()); diff --git a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts index 4f8c8af85956f..bc1591191312b 100644 --- a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts +++ b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts @@ -7,17 +7,16 @@ import type { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { EventService } from '@/events/event.service'; -import type { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import type { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import type { IWorkflowDb } from '@/interfaces'; import type { License } from '@/license'; import type { Push } from '@/push'; -import type { WebSocketPush } from '@/push/websocket.push'; import type { CommunityPackagesService } from '@/services/community-packages.service'; import type { TestWebhooks } from '@/webhooks/test-webhooks'; import type { Publisher } from '../pubsub/publisher.service'; import { PubSubHandler } from '../pubsub/pubsub-handler'; -import type { WorkerStatusService } from '../worker-status.service'; +import type { WorkerStatusService } from '../worker-status.service.ee'; const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); @@ -829,9 +828,7 @@ describe('PubSubHandler', () => { flattedRunData: '[]', }; - push.getBackend.mockReturnValue( - mock({ hasPushRef: jest.fn().mockReturnValue(true) }), - ); + push.hasPushRef.mockReturnValue(true); eventService.emit('relay-execution-lifecycle-event', { type, data, pushRef }); @@ -858,9 +855,7 @@ describe('PubSubHandler', () => { const workflowEntity = mock({ id: 'test-workflow-id' }); const pushRef = 'test-push-ref'; - push.getBackend.mockReturnValue( - mock({ hasPushRef: jest.fn().mockReturnValue(true) }), - ); + push.hasPushRef.mockReturnValue(true); testWebhooks.toWorkflow.mockReturnValue(mock({ id: 'test-workflow-id' })); eventService.emit('clear-test-webhooks', { webhookKey, workflowEntity, pushRef }); diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts index b400bf6dfbbb7..56836b18c6c79 100644 --- a/packages/cli/src/scaling/__tests__/scaling.service.test.ts +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import * as BullModule from 'bull'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import { ApplicationError } from 'n8n-workflow'; +import { ApplicationError, ExecutionCancelledError } from 'n8n-workflow'; import Container from 'typedi'; import { mockInstance, mockLogger } from '@test/mocking'; @@ -287,6 +287,8 @@ describe('ScalingService', () => { const result = await scalingService.stopJob(job); expect(job.progress).toHaveBeenCalledWith({ kind: 'abort-job' }); + expect(job.discard).toHaveBeenCalled(); + expect(job.moveToFailed).toHaveBeenCalledWith(new ExecutionCancelledError('123'), true); expect(result).toBe(true); }); diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 51b86c3922301..16a5efd3b3632 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,6 +1,11 @@ import type { RunningJobSummary } from '@n8n/api-types'; -import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; -import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; +import { InstanceSettings, WorkflowExecute, ErrorReporter, Logger } from 'n8n-core'; +import type { + ExecutionStatus, + IExecuteResponsePromiseData, + IRun, + IWorkflowExecutionDataProcess, +} from 'n8n-workflow'; import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; import { Service } from 'typedi'; @@ -8,7 +13,7 @@ import { Service } from 'typedi'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; +import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -35,6 +40,7 @@ export class JobProcessor { private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, private readonly instanceSettings: InstanceSettings, + private readonly manualExecutionService: ManualExecutionService, ) { this.logger = this.logger.scoped('scaling'); } @@ -116,13 +122,20 @@ export class JobProcessor { executionTimeoutTimestamp, ); + const { pushRef } = job.data; + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( execution.mode, job.data.executionId, 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 = [ async (response: IExecuteResponsePromiseData): Promise => { const msg: RespondToWebhookMessage = { @@ -147,7 +160,31 @@ export class JobProcessor { let workflowExecute: WorkflowExecute; let workflowRun: PCancelable; - 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); workflowRun = workflowExecute.processRunExecutionData(workflow); } else { diff --git a/packages/cli/src/scaling/multi-main-setup.ee.ts b/packages/cli/src/scaling/multi-main-setup.ee.ts index dab9f17cc6dc9..1a2554965c24e 100644 --- a/packages/cli/src/scaling/multi-main-setup.ee.ts +++ b/packages/cli/src/scaling/multi-main-setup.ee.ts @@ -1,10 +1,9 @@ import { GlobalConfig } from '@n8n/config'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { Service } from 'typedi'; import config from '@/config'; import { Time } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { RedisClientService } from '@/services/redis-client.service'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/scaling/pubsub/publisher.service.ts b/packages/cli/src/scaling/pubsub/publisher.service.ts index 4723b1d37dea7..551d1b6caf02f 100644 --- a/packages/cli/src/scaling/pubsub/publisher.service.ts +++ b/packages/cli/src/scaling/pubsub/publisher.service.ts @@ -1,10 +1,9 @@ import type { Redis as SingleNodeClient, Cluster as MultiNodeClient } from 'ioredis'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; +import type { LogMetadata } from 'n8n-workflow'; import { Service } from 'typedi'; import config from '@/config'; -import { Logger } from '@/logging/logger.service'; -import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; diff --git a/packages/cli/src/scaling/pubsub/pubsub-handler.ts b/packages/cli/src/scaling/pubsub/pubsub-handler.ts index 70b5f67f72c37..905902770403e 100644 --- a/packages/cli/src/scaling/pubsub/pubsub-handler.ts +++ b/packages/cli/src/scaling/pubsub/pubsub-handler.ts @@ -7,7 +7,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { EventService } from '@/events/event.service'; import type { PubSubEventMap } from '@/events/maps/pub-sub.event-map'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -16,7 +16,7 @@ import { assertNever } from '@/utils'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import type { PubSub } from './pubsub.types'; -import { WorkerStatusService } from '../worker-status.service'; +import { WorkerStatusService } from '../worker-status.service.ee'; /** * Responsible for handling events emitted from messages received via a pubsub channel. @@ -160,12 +160,12 @@ export class PubSubHandler { 'display-workflow-activation-error': async ({ workflowId, errorMessage }) => this.push.broadcast({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage } }), '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); }, 'clear-test-webhooks': async ({ webhookKey, workflowEntity, pushRef }) => { - if (!this.push.getBackend().hasPushRef(pushRef)) return; + if (!this.push.hasPushRef(pushRef)) return; this.testWebhooks.clearTimeout(webhookKey); diff --git a/packages/cli/src/scaling/pubsub/subscriber.service.ts b/packages/cli/src/scaling/pubsub/subscriber.service.ts index 0ce343c139b0a..c2d5498243006 100644 --- a/packages/cli/src/scaling/pubsub/subscriber.service.ts +++ b/packages/cli/src/scaling/pubsub/subscriber.service.ts @@ -1,13 +1,12 @@ import type { Redis as SingleNodeClient, Cluster as MultiNodeClient } from 'ioredis'; import debounce from 'lodash/debounce'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; +import type { LogMetadata } from 'n8n-workflow'; import { Service } from 'typedi'; import config from '@/config'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; -import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index ebc8e4499ca90..1963aa9ac9fc1 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -1,8 +1,15 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter, InstanceSettings } from 'n8n-core'; -import { ApplicationError, BINARY_ENCODING, sleep, jsonStringify, ensureError } from 'n8n-workflow'; +import { ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; +import { + ApplicationError, + BINARY_ENCODING, + sleep, + jsonStringify, + ensureError, + ExecutionCancelledError, +} from 'n8n-workflow'; import type { IExecuteResponsePromiseData } from 'n8n-workflow'; -import { strict } from 'node:assert'; +import assert, { strict } from 'node:assert'; import Container, { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; @@ -12,7 +19,6 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { OnShutdown } from '@/decorators/on-shutdown'; import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { assertNever } from '@/utils'; @@ -207,7 +213,8 @@ export class ScalingService { try { if (await job.isActive()) { await job.progress({ kind: 'abort-job' }); // being processed by worker - this.logger.debug('Stopped active job', props); + await job.discard(); // prevent retries + await job.moveToFailed(new ExecutionCancelledError(job.data.executionId), true); // remove from queue return true; } @@ -215,8 +222,15 @@ export class ScalingService { this.logger.debug('Stopped inactive job', props); return true; } catch (error: unknown) { - await job.progress({ kind: 'abort-job' }); - this.logger.error('Failed to stop job', { ...props, error }); + assert(error instanceof Error); + this.logger.error('Failed to stop job', { + ...props, + error: { + message: error.message, + name: error.name, + stack: error.stack, + }, + }); return false; } } diff --git a/packages/cli/src/scaling/scaling.types.ts b/packages/cli/src/scaling/scaling.types.ts index ae7e790a16b5d..3c6929417210e 100644 --- a/packages/cli/src/scaling/scaling.types.ts +++ b/packages/cli/src/scaling/scaling.types.ts @@ -12,6 +12,7 @@ export type JobId = Job['id']; export type JobData = { executionId: string; loadStaticData: boolean; + pushRef?: string; }; export type JobResult = { diff --git a/packages/cli/src/scaling/worker-server.ts b/packages/cli/src/scaling/worker-server.ts index ee622d789cbb9..8112f3a4f5bdb 100644 --- a/packages/cli/src/scaling/worker-server.ts +++ b/packages/cli/src/scaling/worker-server.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; import type { Application } from 'express'; import express from 'express'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { strict as assert } from 'node:assert'; import http from 'node:http'; import type { Server } from 'node:http'; @@ -13,7 +13,6 @@ import { CredentialsOverwritesAlreadySetError } from '@/errors/credentials-overw import { NonJsonBodyError } from '@/errors/non-json-body.error'; import { ExternalHooks } from '@/external-hooks'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { PrometheusMetricsService } from '@/metrics/prometheus-metrics.service'; import { rawBodyReader, bodyParser } from '@/middlewares'; import * as ResponseHelper from '@/response-helper'; diff --git a/packages/cli/src/scaling/worker-status.service.ts b/packages/cli/src/scaling/worker-status.service.ee.ts similarity index 100% rename from packages/cli/src/scaling/worker-status.service.ts rename to packages/cli/src/scaling/worker-status.service.ee.ts diff --git a/packages/cli/src/secrets-helpers.ts b/packages/cli/src/secrets-helpers.ee.ts similarity index 90% rename from packages/cli/src/secrets-helpers.ts rename to packages/cli/src/secrets-helpers.ee.ts index 88a75ae3daa07..fdc18c4b85920 100644 --- a/packages/cli/src/secrets-helpers.ts +++ b/packages/cli/src/secrets-helpers.ee.ts @@ -1,7 +1,7 @@ import type { SecretsHelpersBase } from 'n8n-workflow'; import { Service } from 'typedi'; -import { ExternalSecretsManager } from './external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from './external-secrets.ee/external-secrets-manager.ee'; @Service() export class SecretsHelper implements SecretsHelpersBase { diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index 37113e4c40489..92874e87f05e1 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -1,12 +1,11 @@ import { GlobalConfig } from '@n8n/config'; import axios from 'axios'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { Service } from 'typedi'; import config from '@/config'; import { getN8nPackageJson, inDevelopment } from '@/constants'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; -import { Logger } from '@/logging/logger.service'; import { isApiEnabled } from '@/public-api'; import { ENV_VARS_DOCS_URL, diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 74a13114448aa..b2b1c3a1c8bcf 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -23,7 +23,7 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { isLdapEnabled } from '@/ldap/helpers.ee'; +import { isLdapEnabled } from '@/ldap.ee/helpers.ee'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers'; import { PostHogClient } from '@/posthog'; @@ -60,12 +60,12 @@ import '@/credentials/credentials.controller'; import '@/eventbus/event-bus.controller'; import '@/events/events.controller'; import '@/executions/executions.controller'; -import '@/external-secrets/external-secrets.controller.ee'; +import '@/external-secrets.ee/external-secrets.controller.ee'; import '@/license/license.controller'; -import '@/evaluation/test-definitions.controller.ee'; -import '@/evaluation/metrics.controller'; -import '@/evaluation/test-runs.controller.ee'; -import '@/workflows/workflow-history/workflow-history.controller.ee'; +import '@/evaluation.ee/test-definitions.controller.ee'; +import '@/evaluation.ee/metrics.controller'; +import '@/evaluation.ee/test-runs.controller.ee'; +import '@/workflows/workflow-history.ee/workflow-history.controller.ee'; import '@/workflows/workflows.controller'; @Service() @@ -114,8 +114,8 @@ export class Server extends AbstractServer { } if (isLdapEnabled()) { - const { LdapService } = await import('@/ldap/ldap.service.ee'); - await import('@/ldap/ldap.controller.ee'); + const { LdapService } = await import('@/ldap.ee/ldap.service.ee'); + await import('@/ldap.ee/ldap.controller.ee'); await Container.get(LdapService).init(); } @@ -142,9 +142,9 @@ export class Server extends AbstractServer { // initialize SamlService if it is licensed, even if not enabled, to // set up the initial environment try { - const { SamlService } = await import('@/sso/saml/saml.service.ee'); + const { SamlService } = await import('@/sso.ee/saml/saml.service.ee'); await Container.get(SamlService).init(); - await import('@/sso/saml/routes/saml.controller.ee'); + await import('@/sso.ee/saml/routes/saml.controller.ee'); } catch (error) { this.logger.warn(`SAML initialization failed: ${(error as Error).message}`); } @@ -154,11 +154,11 @@ export class Server extends AbstractServer { // ---------------------------------------- try { const { SourceControlService } = await import( - '@/environments/source-control/source-control.service.ee' + '@/environments.ee/source-control/source-control.service.ee' ); await Container.get(SourceControlService).init(); - await import('@/environments/source-control/source-control.controller.ee'); - await import('@/environments/variables/variables.controller.ee'); + await import('@/environments.ee/source-control/source-control.controller.ee'); + await import('@/environments.ee/variables/variables.controller.ee'); } catch (error) { this.logger.warn(`Source Control initialization failed: ${(error as Error).message}`); } diff --git a/packages/cli/src/services/__tests__/ai.service.test.ts b/packages/cli/src/services/__tests__/ai.service.test.ts new file mode 100644 index 0000000000000..dbdcaa3e718ff --- /dev/null +++ b/packages/cli/src/services/__tests__/ai.service.test.ts @@ -0,0 +1,132 @@ +import type { + AiAskRequestDto, + AiApplySuggestionRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; +import type { GlobalConfig } from '@n8n/config'; +import { AiAssistantClient, type AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { mock } from 'jest-mock-extended'; +import type { IUser } from 'n8n-workflow'; + +import { N8N_VERSION } from '@/constants'; +import type { License } from '@/license'; + +import { AiService } from '../ai.service'; + +jest.mock('@n8n_io/ai-assistant-sdk', () => ({ + AiAssistantClient: jest.fn(), +})); + +describe('AiService', () => { + let aiService: AiService; + + const baseUrl = 'https://ai-assistant-url.com'; + const user = mock({ id: 'user123' }); + const client = mock(); + const license = mock(); + const globalConfig = mock({ + logging: { level: 'info' }, + aiAssistant: { baseUrl }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (AiAssistantClient as jest.Mock).mockImplementation(() => client); + aiService = new AiService(license, globalConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('init', () => { + it('should not initialize client if AI assistant is not enabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await aiService.init(); + + expect(AiAssistantClient).not.toHaveBeenCalled(); + }); + + it('should initialize client when AI assistant is enabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + await aiService.init(); + + expect(AiAssistantClient).toHaveBeenCalledWith({ + licenseCert: 'mock-license-cert', + consumerId: 'mock-consumer-id', + n8nVersion: N8N_VERSION, + baseUrl, + logLevel: 'info', + }); + }); + }); + + describe('chat', () => { + const payload = mock(); + + it('should call client chat method after initialization', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.chat.mockResolvedValue(clientResponse); + + const result = await aiService.chat(payload, user); + + expect(client.chat).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.chat(payload, user)).rejects.toThrow('Assistant client not setup'); + }); + }); + + describe('applySuggestion', () => { + const payload = mock(); + + it('should call client applySuggestion', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.applySuggestion.mockResolvedValue(clientResponse); + + const result = await aiService.applySuggestion(payload, user); + + expect(client.applySuggestion).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.applySuggestion(payload, user)).rejects.toThrow( + 'Assistant client not setup', + ); + }); + }); + + describe('askAi', () => { + const payload = mock(); + + it('should call client askAi method after initialization', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.askAi.mockResolvedValue(clientResponse); + + const result = await aiService.askAi(payload, user); + + expect(client.askAi).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.askAi(payload, user)).rejects.toThrow('Assistant client not setup'); + }); + }); +}); diff --git a/packages/cli/src/services/__tests__/orchestration.service.test.ts b/packages/cli/src/services/__tests__/orchestration.service.test.ts index a8e72c49bf64b..45e79036ac0d3 100644 --- a/packages/cli/src/services/__tests__/orchestration.service.test.ts +++ b/packages/cli/src/services/__tests__/orchestration.service.test.ts @@ -5,7 +5,7 @@ import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import config from '@/config'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { Push } from '@/push'; import { OrchestrationService } from '@/services/orchestration.service'; import { RedisClientService } from '@/services/redis-client.service'; diff --git a/packages/cli/src/services/__tests__/password.utility.test.ts b/packages/cli/src/services/__tests__/password.utility.test.ts index 48be7588c2b7b..ff2956844b0a5 100644 --- a/packages/cli/src/services/__tests__/password.utility.test.ts +++ b/packages/cli/src/services/__tests__/password.utility.test.ts @@ -49,57 +49,4 @@ describe('PasswordUtility', () => { expect(isMatch).toBe(false); }); }); - - describe('validate()', () => { - test('should throw on empty password', () => { - const check = () => passwordUtility.validate(); - - expect(check).toThrowError('Password is mandatory'); - }); - - test('should return same password if valid', () => { - const validPassword = 'abcd1234X'; - - const validated = passwordUtility.validate(validPassword); - - expect(validated).toBe(validPassword); - }); - - test('should require at least one uppercase letter', () => { - const invalidPassword = 'abcd1234'; - - const failingCheck = () => passwordUtility.validate(invalidPassword); - - expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.'); - }); - - test('should require at least one number', () => { - const validPassword = 'abcd1234X'; - const invalidPassword = 'abcdEFGH'; - - const validated = passwordUtility.validate(validPassword); - - expect(validated).toBe(validPassword); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must contain at least 1 number.'); - }); - - test('should require a minimum length of 8 characters', () => { - const invalidPassword = 'a'.repeat(7); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must be 8 to 64 characters long.'); - }); - - test('should require a maximum length of 64 characters', () => { - const invalidPassword = 'a'.repeat(65); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must be 8 to 64 characters long.'); - }); - }); }); diff --git a/packages/cli/src/services/active-workflows.service.ts b/packages/cli/src/services/active-workflows.service.ts index 61aa875d1a61a..d2c50c74c2144 100644 --- a/packages/cli/src/services/active-workflows.service.ts +++ b/packages/cli/src/services/active-workflows.service.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import { Service } from 'typedi'; import { ActivationErrorsService } from '@/activation-errors.service'; @@ -5,7 +6,6 @@ import type { User } from '@/databases/entities/user'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { Logger } from '@/logging/logger.service'; @Service() export class ActiveWorkflowsService { diff --git a/packages/cli/src/services/ai.service.ts b/packages/cli/src/services/ai.service.ts index a7b07219b5aca..6a39306ef5ce0 100644 --- a/packages/cli/src/services/ai.service.ts +++ b/packages/cli/src/services/ai.service.ts @@ -1,12 +1,13 @@ +import type { + AiApplySuggestionRequestDto, + AiAskRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; -import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk'; import { assert, type IUser } from 'n8n-workflow'; import { Service } from 'typedi'; -import config from '@/config'; -import type { AiAssistantRequest } from '@/requests'; - import { N8N_VERSION } from '../constants'; import { License } from '../license'; @@ -21,13 +22,14 @@ export class AiService { async init() { const aiAssistantEnabled = this.licenseService.isAiAssistantEnabled(); + if (!aiAssistantEnabled) { return; } const licenseCert = await this.licenseService.loadCertStr(); const consumerId = this.licenseService.getConsumerId(); - const baseUrl = config.get('aiAssistant.baseUrl'); + const baseUrl = this.globalConfig.aiAssistant.baseUrl; const logLevel = this.globalConfig.logging.level; this.client = new AiAssistantClient({ @@ -39,7 +41,7 @@ export class AiService { }); } - async chat(payload: AiAssistantSDK.ChatRequestPayload, user: IUser) { + async chat(payload: AiChatRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -48,7 +50,7 @@ export class AiService { return await this.client.chat(payload, { id: user.id }); } - async applySuggestion(payload: AiAssistantRequest.SuggestionPayload, user: IUser) { + async applySuggestion(payload: AiApplySuggestionRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -57,7 +59,7 @@ export class AiService { return await this.client.applySuggestion(payload, { id: user.id }); } - async askAi(payload: AiAssistantSDK.AskAiRequestPayload, user: IUser) { + async askAi(payload: AiAskRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -65,4 +67,13 @@ export class AiService { return await this.client.askAi(payload, { id: user.id }); } + + async createFreeAiCredits(user: IUser) { + if (!this.client) { + await this.init(); + } + assert(this.client, 'Assistant client not setup'); + + return await this.client.generateAiCreditsCredentials(user); + } } diff --git a/packages/cli/src/services/community-packages.service.ts b/packages/cli/src/services/community-packages.service.ts index 9f09d0c310def..a94b4d5c4f784 100644 --- a/packages/cli/src/services/community-packages.service.ts +++ b/packages/cli/src/services/community-packages.service.ts @@ -2,8 +2,8 @@ import { GlobalConfig } from '@n8n/config'; import axios from 'axios'; import { exec } from 'child_process'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; -import { InstanceSettings } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow'; import { Service } from 'typedi'; import { promisify } from 'util'; @@ -22,7 +22,6 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { CommunityPackages } from '@/interfaces'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { toError } from '@/utils'; diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 6ae7201ac0558..0709d77e06bb5 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -4,7 +4,13 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import get from 'lodash/get'; -import { ErrorReporter, NodeExecuteFunctions, RoutingNode, isObjectLiteral } from 'n8n-core'; +import { + ErrorReporter, + Logger, + NodeExecuteFunctions, + RoutingNode, + isObjectLiteral, +} from 'n8n-core'; import type { ICredentialsDecrypted, ICredentialTestFunction, @@ -28,7 +34,6 @@ import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 1645e98304d56..34b4b3d6b9b69 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -3,7 +3,7 @@ import { GlobalConfig, FrontendConfig, SecurityConfig } from '@n8n/config'; import { createWriteStream } from 'fs'; import { mkdir } from 'fs/promises'; import uniq from 'lodash/uniq'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import type { ICredentialType, INodeTypeBaseDescription } from 'n8n-workflow'; import path from 'path'; import { Container, Service } from 'typedi'; @@ -12,20 +12,19 @@ import config from '@/config'; import { inE2ETests, LICENSE_FEATURES, N8N_VERSION } from '@/constants'; import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { getVariablesLimit } from '@/environments/variables/environment-helpers'; -import { getLdapLoginLabel } from '@/ldap/helpers.ee'; +import { getVariablesLimit } from '@/environments.ee/variables/environment-helpers'; +import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { isApiEnabled } from '@/public-api'; import type { CommunityPackagesService } from '@/services/community-packages.service'; -import { getSamlLoginLabel } from '@/sso/saml/saml-helpers'; -import { getCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { getSamlLoginLabel } from '@/sso.ee/saml/saml-helpers'; +import { getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; import { getWorkflowHistoryLicensePruneTime, getWorkflowHistoryPruneTime, -} from '@/workflows/workflow-history/workflow-history-helper.ee'; +} from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; import { UrlService } from './url.service'; @@ -217,6 +216,10 @@ export class FrontendService { askAi: { enabled: false, }, + aiCredits: { + enabled: false, + credits: 0, + }, workflowHistory: { pruneTime: -1, licensePruneTime: -1, @@ -284,6 +287,7 @@ export class FrontendService { const isS3Licensed = this.license.isBinaryDataS3Licensed(); const isAiAssistantEnabled = this.license.isAiAssistantEnabled(); const isAskAiEnabled = this.license.isAskAiEnabled(); + const isAiCreditsEnabled = this.license.isAiCreditsEnabled(); this.settings.license.planName = this.license.getPlanName(); this.settings.license.consumerId = this.license.getConsumerId(); @@ -344,6 +348,11 @@ export class FrontendService { this.settings.askAi.enabled = isAskAiEnabled; } + if (isAiCreditsEnabled) { + this.settings.aiCredits.enabled = isAiCreditsEnabled; + this.settings.aiCredits.credits = this.license.getAiCredits(); + } + this.settings.mfa.enabled = config.get('mfa.enabled'); this.settings.executionMode = config.getEnv('executions.mode'); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 2402863bab645..4f63357dd67a9 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -11,7 +12,6 @@ import { CredentialsRepository } from '@/databases/repositories/credentials.repo import { TagRepository } from '@/databases/repositories/tag.repository'; import * as Db from '@/db'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { replaceInvalidCredentials } from '@/workflow-helpers'; @Service() diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index d0ad442bc1d73..22403304a16ed 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -87,12 +87,14 @@ export class OwnershipService { id: project.id, type: project.type, name: project.name, + icon: project.icon, }; } else { entity.sharedWithProjects.push({ id: project.id, type: project.type, name: project.name, + icon: project.icon, }); } } diff --git a/packages/cli/src/services/password.utility.ts b/packages/cli/src/services/password.utility.ts index 9719db44bb09f..352002fa95e05 100644 --- a/packages/cli/src/services/password.utility.ts +++ b/packages/cli/src/services/password.utility.ts @@ -1,12 +1,6 @@ import { compare, hash } from 'bcryptjs'; import { Service as Utility } from 'typedi'; -import { - MAX_PASSWORD_CHAR_LENGTH as maxLength, - MIN_PASSWORD_CHAR_LENGTH as minLength, -} from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; - const SALT_ROUNDS = 10; @Utility() @@ -18,28 +12,4 @@ export class PasswordUtility { async compare(plaintext: string, hashed: string) { return await compare(plaintext, hashed); } - - validate(plaintext?: string) { - if (!plaintext) throw new BadRequestError('Password is mandatory'); - - const errorMessages: string[] = []; - - if (plaintext.length < minLength || plaintext.length > maxLength) { - errorMessages.push(`Password must be ${minLength} to ${maxLength} characters long.`); - } - - if (!/\d/.test(plaintext)) { - errorMessages.push('Password must contain at least 1 number.'); - } - - if (!/[A-Z]/.test(plaintext)) { - errorMessages.push('Password must contain at least 1 uppercase letter.'); - } - - if (errorMessages.length > 0) { - throw new BadRequestError(errorMessages.join(' ')); - } - - return plaintext; - } } diff --git a/packages/cli/src/services/project.service.ts b/packages/cli/src/services/project.service.ee.ts similarity index 96% rename from packages/cli/src/services/project.service.ts rename to packages/cli/src/services/project.service.ee.ts index d78e3a07e1d8a..af4505217f9dc 100644 --- a/packages/cli/src/services/project.service.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -7,7 +7,8 @@ import { ApplicationError } from 'n8n-workflow'; import Container, { Service } from 'typedi'; import { UNLIMITED_LICENSE_QUOTA } from '@/constants'; -import { Project, type ProjectType } from '@/databases/entities/project'; +import type { ProjectIcon, ProjectType } from '@/databases/entities/project'; +import { Project } from '@/databases/entities/project'; import { ProjectRelation } from '@/databases/entities/project-relation'; import type { ProjectRole } from '@/databases/entities/project-relation'; import type { User } from '@/databases/entities/user'; @@ -167,7 +168,12 @@ export class ProjectService { return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); } - async createTeamProject(name: string, adminUser: User, id?: string): Promise { + async createTeamProject( + name: string, + adminUser: User, + id?: string, + icon?: ProjectIcon, + ): Promise { const limit = this.license.getTeamProjectLimit(); if ( limit !== UNLIMITED_LICENSE_QUOTA && @@ -180,6 +186,7 @@ export class ProjectService { this.projectRepository.create({ id, name, + icon, type: 'team', }), ); @@ -190,7 +197,11 @@ export class ProjectService { return project; } - async updateProject(name: string, projectId: string): Promise { + async updateProject( + name: string, + projectId: string, + icon?: { type: 'icon' | 'emoji'; value: string }, + ): Promise { const result = await this.projectRepository.update( { id: projectId, @@ -198,6 +209,7 @@ export class ProjectService { }, { name, + icon, }, ); diff --git a/packages/cli/src/services/pruning/pruning.service.ts b/packages/cli/src/services/pruning/pruning.service.ts index aad8c5490f340..15d81dc48ec0b 100644 --- a/packages/cli/src/services/pruning/pruning.service.ts +++ b/packages/cli/src/services/pruning/pruning.service.ts @@ -1,5 +1,5 @@ import { ExecutionsConfig } from '@n8n/config'; -import { BinaryDataService, InstanceSettings } from 'n8n-core'; +import { BinaryDataService, InstanceSettings, Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import { strict } from 'node:assert'; import { Service } from 'typedi'; @@ -8,7 +8,6 @@ import { Time } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { connectionState as dbConnectionState } from '@/db'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '../orchestration.service'; diff --git a/packages/cli/src/services/redis-client.service.ts b/packages/cli/src/services/redis-client.service.ts index c58453016509f..894d3af67102d 100644 --- a/packages/cli/src/services/redis-client.service.ts +++ b/packages/cli/src/services/redis-client.service.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; import ioRedis from 'ioredis'; import type { Cluster, RedisOptions } from 'ioredis'; +import { Logger } from 'n8n-core'; import { Service } from 'typedi'; import { Debounce } from '@/decorators/debounce'; -import { Logger } from '@/logging/logger.service'; import { TypedEmitter } from '@/typed-emitter'; import type { RedisClientType } from '../scaling/redis/redis.types'; diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 97adbbfb7d908..a4fd5daee1d09 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -15,19 +15,19 @@ import { GLOBAL_ADMIN_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES, -} from '@/permissions/global-roles'; +} from '@/permissions.ee/global-roles'; import { PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES, PROJECT_VIEWER_SCOPES, REGULAR_PROJECT_ADMIN_SCOPES, -} from '@/permissions/project-roles'; +} from '@/permissions.ee/project-roles'; import { CREDENTIALS_SHARING_OWNER_SCOPES, CREDENTIALS_SHARING_USER_SCOPES, WORKFLOW_SHARING_EDITOR_SCOPES, WORKFLOW_SHARING_OWNER_SCOPES, -} from '@/permissions/resource-roles'; +} from '@/permissions.ee/resource-roles'; import type { ListQuery } from '@/requests'; export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index e47dd026b0769..3d2cb304719cb 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import type { IUserSettings } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -7,7 +8,6 @@ import { UserRepository } from '@/databases/repositories/user.repository'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; import type { Invitation, PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { PostHogClient } from '@/posthog'; import type { UserRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/services/workflow-statistics.service.ts b/packages/cli/src/services/workflow-statistics.service.ts index 53cbac50947f8..9c0282eb850b0 100644 --- a/packages/cli/src/services/workflow-statistics.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -1,10 +1,10 @@ +import { Logger } from 'n8n-core'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import { Service } from 'typedi'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { UserService } from '@/services/user.service'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/shutdown/shutdown.service.ts b/packages/cli/src/shutdown/shutdown.service.ts index 8ff8570757b86..1597b2a27f43c 100644 --- a/packages/cli/src/shutdown/shutdown.service.ts +++ b/packages/cli/src/shutdown/shutdown.service.ts @@ -1,9 +1,9 @@ import { type Class, ErrorReporter } from 'n8n-core'; +import { Logger } from 'n8n-core'; import { ApplicationError, assert } from 'n8n-workflow'; import { Container, Service } from 'typedi'; import { LOWEST_SHUTDOWN_PRIORITY, HIGHEST_SHUTDOWN_PRIORITY } from '@/constants'; -import { Logger } from '@/logging/logger.service'; type HandlerFn = () => Promise | void; export type ServiceClass = Class>; diff --git a/packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts similarity index 92% rename from packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index 76ae2e4d50500..d75fdc8a7fae5 100644 --- a/packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -3,8 +3,8 @@ import { User } from '@/databases/entities/user'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import * as helpers from '@/sso/saml/saml-helpers'; -import type { SamlUserAttributes } from '@/sso/saml/types/saml-user-attributes'; +import * as helpers from '@/sso.ee/saml/saml-helpers'; +import type { SamlUserAttributes } from '@/sso.ee/saml/types'; import { mockInstance } from '@test/mocking'; const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/src/sso/saml/__tests__/saml-validator.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts similarity index 72% rename from packages/cli/src/sso/saml/__tests__/saml-validator.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts index 8594676ab2999..9f93550bf267a 100644 --- a/packages/cli/src/sso/saml/__tests__/saml-validator.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts @@ -1,10 +1,15 @@ -import { Logger } from '@/logging/logger.service'; -import { mockInstance } from '@test/mocking'; +import { mock } from 'jest-mock-extended'; -import { validateMetadata, validateResponse } from '../saml-validator'; +import { SamlValidator } from '../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', () => { test('successfully validates metadata containing ws federation tags', async () => { @@ -30,8 +35,7 @@ describe('saml-validator', () => { DQnnT/5se4dqYN86R35MCdbyKVl64lGPLSIVrxFxrOQ9YRK1br7Z1Bt1/LQD4f92z+GwAl+9tZTWhuoy6OGHCV6LlqBEztW43KnlCKw6eaNg4/6NluzJ/XeknXYLURDnfFVyGbLQAYWGND4Qm8CUXO/GjGfWTZuArvrDDC36/2FA41jKXtf1InxGFx1Bbaskx3n3KCFFth/V9knbnc1zftEe022aQluPRoGccROOI4ZeLUFL6+1gYlxjx0gFIOTRiuvrzR765lHNrF7iZ4aD+XukqtkGEtxTkiLoB+Bnr8Fd7IF5rV5FKTZWSxo+ZFcLimrDGtFPItVrC/oKRc+MGA== - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -42,8 +46,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -168,8 +171,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -193,8 +195,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -208,7 +209,7 @@ describe('saml-validator', () => { `; // ACT - const result = await validateMetadata(metadata); + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(true); @@ -224,7 +225,85 @@ describe('saml-validator', () => { `; // 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 = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; // Missing closing tags + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata missing SingleSignOnService', async () => { + // ARRANGE + const metadata = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata with invalid X.509 certificate', async () => { + // ARRANGE + const metadata = ` + + + + + + + INVALID_CERTIFICATE + + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(false); @@ -327,13 +406,13 @@ describe('saml-validator', () => { `; // ACT - const result = await validateResponse(response); + const result = await validator.validateResponse(response); // ASSERT expect(result).toBe(true); }); - test('rejects invalidate response', async () => { + test('rejects invalid response', async () => { // ARRANGE // Invalid because required children are missing const response = ` { `; // 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 = ` + + https://sts.windows.net/random-issuer/ + + + + + https://sts.windows.net/random-issuer/ + + + random_name_id + + + + + // Expired + + http://localhost:5678/rest/sso/saml/metadata + + + + `; + + // ACT + const result = await validator.validateResponse(response); // ASSERT expect(result).toBe(false); diff --git a/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts similarity index 91% rename from packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts index 8bd5e32da20a5..ebf34e3075abe 100644 --- a/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts @@ -3,20 +3,16 @@ import { mock } from 'jest-mock-extended'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { Logger } from '@/logging/logger.service'; -import { UrlService } from '@/services/url.service'; -import * as samlHelpers from '@/sso/saml/saml-helpers'; -import { SamlService } from '@/sso/saml/saml.service.ee'; +import * as samlHelpers from '@/sso.ee/saml/saml-helpers'; +import { SamlService } from '@/sso.ee/saml/saml.service.ee'; import { mockInstance } from '@test/mocking'; import { SAML_PREFERENCES_DB_KEY } from '../constants'; import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error'; describe('SamlService', () => { - const logger = mockInstance(Logger); - const urlService = mockInstance(UrlService); - const samlService = new SamlService(logger, urlService); const settingsRepository = mockInstance(SettingsRepository); + const samlService = new SamlService(mock(), mock(), mock(), mock(), settingsRepository); beforeEach(() => { jest.restoreAllMocks(); diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso.ee/saml/constants.ts similarity index 100% rename from packages/cli/src/sso/saml/constants.ts rename to packages/cli/src/sso.ee/saml/constants.ts diff --git a/packages/cli/src/sso/saml/errors/invalid-saml-metadata.error.ts b/packages/cli/src/sso.ee/saml/errors/invalid-saml-metadata.error.ts similarity index 100% rename from packages/cli/src/sso/saml/errors/invalid-saml-metadata.error.ts rename to packages/cli/src/sso.ee/saml/errors/invalid-saml-metadata.error.ts diff --git a/packages/cli/src/sso/saml/middleware/saml-enabled-middleware.ts b/packages/cli/src/sso.ee/saml/middleware/saml-enabled-middleware.ts similarity index 100% rename from packages/cli/src/sso/saml/middleware/saml-enabled-middleware.ts rename to packages/cli/src/sso.ee/saml/middleware/saml-enabled-middleware.ts diff --git a/packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts similarity index 63% rename from packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts rename to packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts index c4a33ed441af4..928f6d6df0ba7 100644 --- a/packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts @@ -2,18 +2,14 @@ import { type Response } from 'express'; import { mock } from 'jest-mock-extended'; import type { User } from '@/databases/entities/user'; -import { UrlService } from '@/services/url.service'; -import { mockInstance } from '@test/mocking'; +import type { AuthlessRequest } from '@/requests'; -import { SamlService } from '../../saml.service.ee'; +import type { SamlService } from '../../saml.service.ee'; import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee'; -import type { SamlConfiguration } from '../../types/requests'; -import type { SamlUserAttributes } from '../../types/saml-user-attributes'; +import type { SamlUserAttributes } from '../../types'; import { SamlController } from '../saml.controller.ee'; -const urlService = mockInstance(UrlService); -urlService.getInstanceBaseUrl.mockReturnValue(''); -const samlService = mockInstance(SamlService); +const samlService = mock(); const controller = new SamlController(mock(), samlService, mock(), mock()); const user = mock({ @@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = { }; describe('Test views', () => { + const RelayState = getServiceProviderConfigTestReturnUrl(); + test('Should render success with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); samlService.handleSamlLogin.mockResolvedValueOnce({ authenticatedUser: user, attributes, onboardingRequired: false, }); - await controller.acsPost(req, res); + await controller.acsPost(req, res, { RelayState }); expect(res.render).toBeCalledWith('saml-connection-test-success', attributes); }); test('Should render failure with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); samlService.handleSamlLogin.mockResolvedValueOnce({ authenticatedUser: undefined, attributes, onboardingRequired: false, }); - await controller.acsPost(req, res); + await controller.acsPost(req, res, { RelayState }); expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes }); }); test('Should render error with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); 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' }); }); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts similarity index 77% rename from packages/cli/src/sso/saml/routes/saml.controller.ee.ts rename to packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts index c7b954914b98e..c8f636eec4549 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts @@ -1,15 +1,14 @@ -import { validate } from 'class-validator'; -import express from 'express'; +import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types'; +import { Response } from 'express'; import querystring from 'querystring'; import type { PostBindingContext } from 'samlify/types/src/entity'; import url from 'url'; 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 { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; -import { AuthenticatedRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { sendErrorResponse } from '@/response-helper'; import { UrlService } from '@/services/url.service'; @@ -25,7 +24,6 @@ import { getServiceProviderReturnUrl, } from '../service-provider.ee'; import type { SamlLoginBinding } from '../types'; -import { SamlConfiguration } from '../types/requests'; import { getInitSSOFormView } from '../views/init-sso-post'; @RestController('/sso/saml') @@ -38,7 +36,7 @@ export class SamlController { ) {} @Get('/metadata', { skipAuth: true }) - async getServiceProviderMetadata(_: express.Request, res: express.Response) { + async getServiceProviderMetadata(_: AuthlessRequest, res: Response) { return res .header('Content-Type', 'text/xml') .send(this.samlService.getServiceProviderInstance().getMetadata()); @@ -62,17 +60,8 @@ export class SamlController { */ @Post('/config', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async configPost(req: SamlConfiguration.Update) { - const validationResult = await validate(req.body); - 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(','), - ); - } + async configPost(_req: AuthenticatedRequest, _res: Response, @Body payload: SamlPreferences) { + return await this.samlService.setSamlPreferences(payload); } /** @@ -80,11 +69,12 @@ export class SamlController { */ @Post('/config/toggle', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { - if (req.body.loginEnabled === undefined) { - throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); - } - await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled }); + async toggleEnabledPost( + _req: AuthenticatedRequest, + res: Response, + @Body { loginEnabled }: SamlToggleDto, + ) { + await this.samlService.setSamlPreferences({ loginEnabled }); return res.sendStatus(200); } @@ -92,7 +82,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @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'); } @@ -100,8 +90,8 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true }) - async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { - return await this.acsHandler(req, res, 'post'); + async acsPost(req: AuthlessRequest, res: Response, @Body payload: SamlAcsDto) { + 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 */ private async acsHandler( - req: SamlConfiguration.AcsRequest, - res: express.Response, + req: AuthlessRequest, + res: Response, binding: SamlLoginBinding, + payload: SamlAcsDto = {}, ) { try { const loginResult = await this.samlService.handleSamlLogin(req, binding); // if RelayState is set to the test connection Url, this is a test connection - if (isConnectionTestRequest(req)) { + if (isConnectionTestRequest(payload)) { if (loginResult.authenticatedUser) { return res.render('saml-connection-test-success', loginResult.attributes); } else { @@ -139,7 +130,7 @@ export class SamlController { if (loginResult.onboardingRequired) { return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding'); } else { - const redirectUrl = req.body?.RelayState ?? '/'; + const redirectUrl = payload.RelayState ?? '/'; return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl); } } else { @@ -153,7 +144,7 @@ export class SamlController { // Need to manually send the error response since we're using templates return sendErrorResponse(res, new AuthError('SAML Authentication failed')); } catch (error) { - if (isConnectionTestRequest(req)) { + if (isConnectionTestRequest(payload)) { return res.render('saml-connection-test-failed', { message: (error as Error).message }); } this.eventService.emit('user-login-failed', { @@ -173,7 +164,7 @@ export class SamlController { * This endpoint is available if SAML is licensed and enabled */ @Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true }) - async initSsoGet(req: express.Request, res: express.Response) { + async initSsoGet(req: AuthlessRequest, res: Response) { let redirectUrl = ''; try { const refererUrl = req.headers.referer; @@ -198,11 +189,11 @@ export class SamlController { */ @Get('/config/test', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async configTestGet(_: AuthenticatedRequest, res: express.Response) { + async configTestGet(_: AuthenticatedRequest, res: Response) { 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); if (result?.binding === 'redirect') { return result.context.context; diff --git a/packages/cli/src/sso/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts similarity index 93% rename from packages/cli/src/sso/saml/saml-helpers.ts rename to packages/cli/src/sso.ee/saml/saml-helpers.ts index 996e17b359832..8479314d4b0b8 100644 --- a/packages/cli/src/sso/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -1,3 +1,4 @@ +import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types'; import { randomString } from 'n8n-workflow'; import type { FlowResult } from 'samlify/types/src/flow'; import { Container } from 'typedi'; @@ -14,10 +15,7 @@ import { PasswordUtility } from '@/services/password.utility'; import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee'; -import type { SamlConfiguration } from './types/requests'; -import type { SamlAttributeMapping } from './types/saml-attribute-mapping'; -import type { SamlPreferences } from './types/saml-preferences'; -import type { SamlUserAttributes } from './types/saml-user-attributes'; +import type { SamlAttributeMapping, SamlUserAttributes } from './types'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, @@ -165,6 +163,6 @@ export function getMappedSamlAttributesFromFlowResult( return result; } -export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { - return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); +export function isConnectionTestRequest(payload: SamlAcsDto): boolean { + return payload.RelayState === getServiceProviderConfigTestReturnUrl(); } diff --git a/packages/cli/src/sso.ee/saml/saml-validator.ts b/packages/cli/src/sso.ee/saml/saml-validator.ts new file mode 100644 index 0000000000000..570b279c721d6 --- /dev/null +++ b/packages/cli/src/sso.ee/saml/saml-validator.ts @@ -0,0 +1,87 @@ +import { Logger } from 'n8n-core'; +import { Service } from 'typedi'; +import type { XMLFileInfo, XMLLintOptions, XMLValidationResult } from 'xmllint-wasm'; + +@Service() +export class SamlValidator { + private xmlMetadata: XMLFileInfo; + + private xmlProtocol: XMLFileInfo; + + private preload: XMLFileInfo[] = []; + + constructor(private readonly logger: Logger) {} + + private xmllint: { + validateXML: (options: XMLLintOptions) => Promise; + }; + + async init() { + await this.loadSchemas(); + this.xmllint = await import('xmllint-wasm'); + } + + async validateMetadata(metadata: string): Promise { + return await this.validateXml('metadata', metadata); + } + + async validateResponse(response: string): Promise { + return await this.validateXml('response', response); + } + + // dynamically load schema files + private async loadSchemas(): Promise { + this.xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo; + this.xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo; + this.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 + import('./schema/ws-federation.xsd'), + 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); + } + + private async validateXml(type: 'metadata' | 'response', contents: string): Promise { + 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); + } + return false; + } +} diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso.ee/saml/saml.service.ee.ts similarity index 91% rename from packages/cli/src/sso/saml/saml.service.ee.ts rename to packages/cli/src/sso.ee/saml/saml.service.ee.ts index 3672c8fe6ff23..fd03471b671d4 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso.ee/saml/saml.service.ee.ts @@ -1,10 +1,12 @@ +import type { SamlPreferences } from '@n8n/api-types'; import axios from 'axios'; import type express from 'express'; import https from 'https'; +import { Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; 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 { User } from '@/databases/entities/user'; @@ -12,7 +14,6 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; import { SAML_PREFERENCES_DB_KEY } from './constants'; @@ -27,11 +28,9 @@ import { setSamlLoginLabel, updateUserFromSamlAttributes, } from './saml-helpers'; -import { validateMetadata, validateResponse } from './saml-validator'; +import { SamlValidator } from './saml-validator'; import { getServiceProviderInstance } from './service-provider.ee'; -import type { SamlLoginBinding } from './types'; -import type { SamlPreferences } from './types/saml-preferences'; -import type { SamlUserAttributes } from './types/saml-user-attributes'; +import type { SamlLoginBinding, SamlUserAttributes } from './types'; import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers'; @Service() @@ -79,12 +78,16 @@ export class SamlService { constructor( private readonly logger: Logger, private readonly urlService: UrlService, + private readonly validator: SamlValidator, + private readonly userRepository: UserRepository, + private readonly settingsRepository: SettingsRepository, ) {} async init(): Promise { try { // load preferences first but do not apply so as to not load samlify unnecessarily await this.loadFromDbAndApplySamlPreferences(false); + await this.validator.init(); if (isSamlLicensedAndEnabled()) { await this.loadSamlify(); await this.loadFromDbAndApplySamlPreferences(true); @@ -108,9 +111,10 @@ export class SamlService { this.logger.debug('Loading samlify library into memory'); this.samlify = await import('samlify'); } + this.samlify.setSchemaValidator({ validate: async (response: string) => { - const valid = await validateResponse(response); + const valid = await this.validator.validateResponse(response); if (!valid) { throw new InvalidSamlMetadataError(); } @@ -188,7 +192,7 @@ export class SamlService { const attributes = await this.getAttributesFromLoginResponse(req, binding); if (attributes.email) { const lowerCasedEmail = attributes.email.toLowerCase(); - const user = await Container.get(UserRepository).findOne({ + const user = await this.userRepository.findOne({ where: { email: lowerCasedEmail }, relations: ['authIdentities'], }); @@ -233,7 +237,7 @@ export class SamlService { }; } - async setSamlPreferences(prefs: SamlPreferences): Promise { + async setSamlPreferences(prefs: Partial): Promise { await this.loadSamlify(); await this.loadPreferencesWithoutValidation(prefs); if (prefs.metadataUrl) { @@ -242,7 +246,7 @@ export class SamlService { this._samlPreferences.metadata = fetchedMetadata; } } else if (prefs.metadata) { - const validationResult = await validateMetadata(prefs.metadata); + const validationResult = await this.validator.validateMetadata(prefs.metadata); if (!validationResult) { throw new InvalidSamlMetadataError(); } @@ -252,7 +256,7 @@ export class SamlService { return result; } - async loadPreferencesWithoutValidation(prefs: SamlPreferences) { + async loadPreferencesWithoutValidation(prefs: Partial) { this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; @@ -278,7 +282,7 @@ export class SamlService { } async loadFromDbAndApplySamlPreferences(apply = true): Promise { - const samlPreferences = await Container.get(SettingsRepository).findOne({ + const samlPreferences = await this.settingsRepository.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); if (samlPreferences) { @@ -296,18 +300,18 @@ export class SamlService { } async saveSamlPreferencesToDb(): Promise { - const samlPreferences = await Container.get(SettingsRepository).findOne({ + const samlPreferences = await this.settingsRepository.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); const settingsValue = JSON.stringify(this.samlPreferences); let result: Settings; if (samlPreferences) { samlPreferences.value = settingsValue; - result = await Container.get(SettingsRepository).save(samlPreferences, { + result = await this.settingsRepository.save(samlPreferences, { transaction: false, }); } else { - result = await Container.get(SettingsRepository).save( + result = await this.settingsRepository.save( { key: SAML_PREFERENCES_DB_KEY, value: settingsValue, @@ -332,7 +336,7 @@ export class SamlService { const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent }); if (response.status === 200 && response.data) { const xml = (await response.data) as string; - const validationResult = await validateMetadata(xml); + const validationResult = await this.validator.validateMetadata(xml); if (!validationResult) { throw new BadRequestError( `Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`, @@ -392,6 +396,6 @@ export class SamlService { */ async reset() { await setSamlLoginEnabled(false); - await Container.get(SettingsRepository).delete({ key: SAML_PREFERENCES_DB_KEY }); + await this.settingsRepository.delete({ key: SAML_PREFERENCES_DB_KEY }); } } diff --git a/packages/cli/src/sso/saml/schema/metadata-exchange.xsd.ts b/packages/cli/src/sso.ee/saml/schema/metadata-exchange.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/metadata-exchange.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/metadata-exchange.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-assertion-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-assertion-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-assertion-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-assertion-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-metadata-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-metadata-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-metadata-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-metadata-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-protocol-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-protocol-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-protocol-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-protocol-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-addr.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-addr.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-addr.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-addr.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-authorization.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-authorization.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-authorization.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-authorization.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-federation.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-federation.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-federation.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-federation.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-securitypolicy-1.2.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-securitypolicy-1.2.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-securitypolicy-1.2.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-securitypolicy-1.2.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xenc-schema.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xenc-schema.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xenc-schema.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xenc-schema.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xml.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xml.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xml.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xml.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xmldsig-core-schema.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xmldsig-core-schema.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xmldsig-core-schema.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xmldsig-core-schema.xsd.ts diff --git a/packages/cli/src/sso/saml/service-provider.ee.ts b/packages/cli/src/sso.ee/saml/service-provider.ee.ts similarity index 96% rename from packages/cli/src/sso/saml/service-provider.ee.ts rename to packages/cli/src/sso.ee/saml/service-provider.ee.ts index 2e6511df09004..0522c80b5188d 100644 --- a/packages/cli/src/sso/saml/service-provider.ee.ts +++ b/packages/cli/src/sso.ee/saml/service-provider.ee.ts @@ -1,10 +1,9 @@ +import type { SamlPreferences } from '@n8n/api-types'; import type { ServiceProviderInstance } from 'samlify'; import { Container } from 'typedi'; import { UrlService } from '@/services/url.service'; -import type { SamlPreferences } from './types/saml-preferences'; - let serviceProviderInstance: ServiceProviderInstance | undefined; export function getServiceProviderEntityId(): string { diff --git a/packages/cli/src/sso.ee/saml/types.ts b/packages/cli/src/sso.ee/saml/types.ts new file mode 100644 index 0000000000000..35687777b1ae8 --- /dev/null +++ b/packages/cli/src/sso.ee/saml/types.ts @@ -0,0 +1,5 @@ +import type { SamlPreferences } from '@n8n/api-types'; + +export type SamlLoginBinding = SamlPreferences['loginBinding']; +export type SamlAttributeMapping = NonNullable; +export type SamlUserAttributes = SamlAttributeMapping; diff --git a/packages/cli/src/sso/saml/views/init-sso-post.ts b/packages/cli/src/sso.ee/saml/views/init-sso-post.ts similarity index 100% rename from packages/cli/src/sso/saml/views/init-sso-post.ts rename to packages/cli/src/sso.ee/saml/views/init-sso-post.ts diff --git a/packages/cli/src/sso/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts similarity index 100% rename from packages/cli/src/sso/sso-helpers.ts rename to packages/cli/src/sso.ee/sso-helpers.ts diff --git a/packages/cli/src/sso/saml/saml-validator.ts b/packages/cli/src/sso/saml/saml-validator.ts deleted file mode 100644 index 07e9853f90044..0000000000000 --- a/packages/cli/src/sso/saml/saml-validator.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Container } from 'typedi'; -import type { XMLFileInfo } from 'xmllint-wasm'; - -import { Logger } from '@/logging/logger.service'; - -let xmlMetadata: XMLFileInfo; -let xmlProtocol: XMLFileInfo; - -let preload: XMLFileInfo[] = []; - -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -let xmllintWasm: typeof import('xmllint-wasm') | undefined; - -// dynamically load schema files -async function loadSchemas(): Promise { - 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 - import('./schema/ws-federation.xsd'), - 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 function loadXmllintWasm(): Promise { - if (xmllintWasm === undefined) { - Container.get(Logger).debug('Loading xmllint-wasm library into memory'); - xmllintWasm = await import('xmllint-wasm'); - } -} - -export async function validateMetadata(metadata: string): Promise { - const logger = Container.get(Logger); - try { - await loadXmllintWasm(); - await loadSchemas(); - const validationResult = await xmllintWasm?.validateXML({ - xml: [ - { - fileName: 'metadata.xml', - contents: metadata, - }, - ], - extension: 'schema', - schema: [xmlMetadata], - preload: [xmlProtocol, ...preload], - }); - if (validationResult?.valid) { - logger.debug('SAML Metadata is valid'); - return true; - } else { - logger.warn('SAML Validate Metadata: Invalid metadata'); - 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; -} - -export async function validateResponse(response: string): Promise { - 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; -} diff --git a/packages/cli/src/sso/saml/types/index.ts b/packages/cli/src/sso/saml/types/index.ts deleted file mode 100644 index 560f7003f821a..0000000000000 --- a/packages/cli/src/sso/saml/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type SamlLoginBinding = 'post' | 'redirect'; diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts deleted file mode 100644 index 69fb89a1ebec9..0000000000000 --- a/packages/cli/src/sso/saml/types/requests.ts +++ /dev/null @@ -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; - }, - {} - >; -} diff --git a/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts b/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts deleted file mode 100644 index af7dd76e23f7c..0000000000000 --- a/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlAttributeMapping { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/src/sso/saml/types/saml-preferences.ts b/packages/cli/src/sso/saml/types/saml-preferences.ts deleted file mode 100644 index 1231684360a55..0000000000000 --- a/packages/cli/src/sso/saml/types/saml-preferences.ts +++ /dev/null @@ -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 = ''; -} diff --git a/packages/cli/src/sso/saml/types/saml-user-attributes.ts b/packages/cli/src/sso/saml/types/saml-user-attributes.ts deleted file mode 100644 index fa3c849f65723..0000000000000 --- a/packages/cli/src/sso/saml/types/saml-user-attributes.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlUserAttributes { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts b/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts index 6c64fc0b3a23f..0415977e2e10f 100644 --- a/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts +++ b/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Logger } from 'n8n-core'; import { type Workflow, type INode, type WorkflowSettings } from 'n8n-workflow'; import { Service } from 'typedi'; import type { Project } from '@/databases/entities/project'; import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; -import { Logger } from '@/logging/logger.service'; import { AccessService } from '@/services/access.service'; import { OwnershipService } from '@/services/ownership.service'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/runners/__tests__/forward-to-logger.test.ts b/packages/cli/src/task-runners/__tests__/forward-to-logger.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/forward-to-logger.test.ts rename to packages/cli/src/task-runners/__tests__/forward-to-logger.test.ts diff --git a/packages/cli/src/runners/__tests__/node-process-oom-detector.test.ts b/packages/cli/src/task-runners/__tests__/node-process-oom-detector.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/node-process-oom-detector.test.ts rename to packages/cli/src/task-runners/__tests__/node-process-oom-detector.test.ts diff --git a/packages/cli/src/runners/__tests__/sliding-window-signal.test.ts b/packages/cli/src/task-runners/__tests__/sliding-window-signal.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/sliding-window-signal.test.ts rename to packages/cli/src/task-runners/__tests__/sliding-window-signal.test.ts diff --git a/packages/cli/src/runners/__tests__/task-broker.test.ts b/packages/cli/src/task-runners/__tests__/task-broker.test.ts similarity index 99% rename from packages/cli/src/runners/__tests__/task-broker.test.ts rename to packages/cli/src/task-runners/__tests__/task-broker.test.ts index 1f5030ada8e93..ced7e1c07e5ab 100644 --- a/packages/cli/src/runners/__tests__/task-broker.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-broker.test.ts @@ -7,9 +7,9 @@ import { Time } from '@/constants'; import { TaskRejectError } from '../errors'; import { TaskRunnerTimeoutError } from '../errors/task-runner-timeout.error'; -import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; import { TaskBroker } from '../task-broker.service'; import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service'; +import type { TaskRunnerLifecycleEvents } from '../task-runner-lifecycle-events'; const createValidUntil = (ms: number) => process.hrtime.bigint() + BigInt(ms * 1_000_000); @@ -718,7 +718,7 @@ describe('TaskBroker', () => { describe('task timeouts', () => { let taskBroker: TaskBroker; let config: TaskRunnersConfig; - let runnerLifecycleEvents = mock(); + let runnerLifecycleEvents = mock(); beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts similarity index 74% rename from packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts index 61cfb8b8e85cf..bf3bab4c273ae 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts @@ -1,12 +1,12 @@ import { TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; +import type { Logger } from 'n8n-core'; -import type { Logger } from '@/logging/logger.service'; -import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service'; -import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import { RunnerLifecycleEvents } from '@/runners/runner-lifecycle-events'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import type { TaskRunnerAuthService } from '@/task-runners/auth/task-runner-auth.service'; +import { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import { TaskRunnerLifecycleEvents } from '@/task-runners/task-runner-lifecycle-events'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; describe('TaskRunnerProcessRestartLoopDetector', () => { const mockLogger = mock(); @@ -16,7 +16,7 @@ describe('TaskRunnerProcessRestartLoopDetector', () => { mockLogger, runnerConfig, mockAuthService, - new RunnerLifecycleEvents(), + new TaskRunnerLifecycleEvents(), ); it('should detect a restart loop if process exits 5 times within 5s', () => { diff --git a/packages/cli/src/runners/__tests__/task-runner-process.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts similarity index 88% rename from packages/cli/src/runners/__tests__/task-runner-process.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-process.test.ts index 85dbaa6930884..d00ce7b88f221 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts @@ -1,13 +1,13 @@ import { TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; -import { Logger } from '@/logging/logger.service'; -import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; +import type { TaskRunnerAuthService } from '@/task-runners/auth/task-runner-auth.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; import { mockInstance } from '@test/mocking'; -import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; +import type { TaskRunnerLifecycleEvents } from '../task-runner-lifecycle-events'; const spawnMock = jest.fn(() => mock({ @@ -43,7 +43,7 @@ describe('TaskRunnerProcess', () => { }); it('should register listener for `runner:failed-heartbeat-check` event', () => { - const runnerLifecycleEvents = mock(); + const runnerLifecycleEvents = mock(); new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( @@ -53,7 +53,7 @@ describe('TaskRunnerProcess', () => { }); it('should register listener for `runner:timed-out-during-task` event', () => { - const runnerLifecycleEvents = mock(); + const runnerLifecycleEvents = mock(); new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( diff --git a/packages/cli/src/runners/__tests__/task-runner-server.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-server.test.ts similarity index 87% rename from packages/cli/src/runners/__tests__/task-runner-server.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-server.test.ts index ae25cd1231bf0..33de18c605312 100644 --- a/packages/cli/src/runners/__tests__/task-runner-server.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-server.test.ts @@ -3,10 +3,10 @@ import { mock } from 'jest-mock-extended'; import { ServerResponse } from 'node:http'; import type WebSocket from 'ws'; -import type { TaskRunnerAuthController } from '@/runners/auth/task-runner-auth.controller'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; +import type { TaskRunnerAuthController } from '@/task-runners/auth/task-runner-auth.controller'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; -import type { TaskRunnerServerInitRequest } from '../runner-types'; +import type { TaskRunnerServerInitRequest } from '../task-runner-types'; describe('TaskRunnerServer', () => { describe('handleUpgradeRequest', () => { diff --git a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts similarity index 96% rename from packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts index 24b12fa1906c1..cabedc530b32c 100644 --- a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts @@ -3,7 +3,7 @@ import { mock } from 'jest-mock-extended'; import type WebSocket from 'ws'; import { Time, WsStatusCodes } from '@/constants'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; describe('TaskRunnerWsServer', () => { describe('removeConnection', () => { diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts similarity index 97% rename from packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts rename to packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts index 7d43f91458d17..3c650d1644790 100644 --- a/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts +++ b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts @@ -8,7 +8,7 @@ import { mockInstance } from '@test/mocking'; import { BadRequestError } from '../../../errors/response-errors/bad-request.error'; import { ForbiddenError } from '../../../errors/response-errors/forbidden.error'; import type { AuthlessRequest } from '../../../requests'; -import type { TaskRunnerServerInitRequest } from '../../runner-types'; +import type { TaskRunnerServerInitRequest } from '../../task-runner-types'; import { TaskRunnerAuthController } from '../task-runner-auth.controller'; import { TaskRunnerAuthService } from '../task-runner-auth.service'; diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.service.test.ts similarity index 100% rename from packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts rename to packages/cli/src/task-runners/auth/__tests__/task-runner-auth.service.test.ts diff --git a/packages/cli/src/runners/auth/task-runner-auth.controller.ts b/packages/cli/src/task-runners/auth/task-runner-auth.controller.ts similarity index 96% rename from packages/cli/src/runners/auth/task-runner-auth.controller.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.controller.ts index a117dfca0dc4d..0213d2b4082ec 100644 --- a/packages/cli/src/runners/auth/task-runner-auth.controller.ts +++ b/packages/cli/src/task-runners/auth/task-runner-auth.controller.ts @@ -7,7 +7,7 @@ import { taskRunnerAuthRequestBodySchema } from './task-runner-auth.schema'; import { TaskRunnerAuthService } from './task-runner-auth.service'; import { BadRequestError } from '../../errors/response-errors/bad-request.error'; import { ForbiddenError } from '../../errors/response-errors/forbidden.error'; -import type { TaskRunnerServerInitRequest } from '../runner-types'; +import type { TaskRunnerServerInitRequest } from '../task-runner-types'; /** * Controller responsible for authenticating Task Runner connections diff --git a/packages/cli/src/runners/auth/task-runner-auth.schema.ts b/packages/cli/src/task-runners/auth/task-runner-auth.schema.ts similarity index 100% rename from packages/cli/src/runners/auth/task-runner-auth.schema.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.schema.ts diff --git a/packages/cli/src/runners/auth/task-runner-auth.service.ts b/packages/cli/src/task-runners/auth/task-runner-auth.service.ts similarity index 100% rename from packages/cli/src/runners/auth/task-runner-auth.service.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.service.ts diff --git a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts b/packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts similarity index 97% rename from packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts rename to packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts index 9db537ee9556b..3033a1f7626f1 100644 --- a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts @@ -4,7 +4,7 @@ import config from '@/config'; import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error'; import { TaskRunnerFailedHeartbeatError } from './errors/task-runner-failed-heartbeat.error'; -import type { DisconnectAnalyzer, DisconnectErrorOptions } from './runner-types'; +import type { DisconnectAnalyzer, DisconnectErrorOptions } from './task-runner-types'; /** * Analyzes the disconnect reason of a task runner to provide a more diff --git a/packages/cli/src/runners/errors.ts b/packages/cli/src/task-runners/errors.ts similarity index 100% rename from packages/cli/src/runners/errors.ts rename to packages/cli/src/task-runners/errors.ts diff --git a/packages/cli/src/runners/errors/__tests__/task-runner-disconnected-error.test.ts b/packages/cli/src/task-runners/errors/__tests__/task-runner-disconnected-error.test.ts similarity index 100% rename from packages/cli/src/runners/errors/__tests__/task-runner-disconnected-error.test.ts rename to packages/cli/src/task-runners/errors/__tests__/task-runner-disconnected-error.test.ts diff --git a/packages/cli/src/runners/errors/missing-auth-token.error.ts b/packages/cli/src/task-runners/errors/missing-auth-token.error.ts similarity index 100% rename from packages/cli/src/runners/errors/missing-auth-token.error.ts rename to packages/cli/src/task-runners/errors/missing-auth-token.error.ts diff --git a/packages/cli/src/runners/errors/task-runner-disconnected-error.ts b/packages/cli/src/task-runners/errors/task-runner-disconnected-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-disconnected-error.ts rename to packages/cli/src/task-runners/errors/task-runner-disconnected-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts b/packages/cli/src/task-runners/errors/task-runner-failed-heartbeat.error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts rename to packages/cli/src/task-runners/errors/task-runner-failed-heartbeat.error.ts diff --git a/packages/cli/src/runners/errors/task-runner-oom-error.ts b/packages/cli/src/task-runners/errors/task-runner-oom-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-oom-error.ts rename to packages/cli/src/task-runners/errors/task-runner-oom-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts b/packages/cli/src/task-runners/errors/task-runner-restart-loop-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-restart-loop-error.ts rename to packages/cli/src/task-runners/errors/task-runner-restart-loop-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-timeout.error.ts b/packages/cli/src/task-runners/errors/task-runner-timeout.error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-timeout.error.ts rename to packages/cli/src/task-runners/errors/task-runner-timeout.error.ts diff --git a/packages/cli/src/runners/forward-to-logger.ts b/packages/cli/src/task-runners/forward-to-logger.ts similarity index 100% rename from packages/cli/src/runners/forward-to-logger.ts rename to packages/cli/src/task-runners/forward-to-logger.ts diff --git a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts b/packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts similarity index 96% rename from packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts rename to packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts index 26d8de568352c..0af942bc9e500 100644 --- a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts @@ -3,10 +3,10 @@ import { Service } from 'typedi'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; import { TaskRunnerOomError } from './errors/task-runner-oom-error'; -import type { DisconnectErrorOptions } from './runner-types'; import { SlidingWindowSignal } from './sliding-window-signal'; import type { ExitReason, TaskRunnerProcessEventMap } from './task-runner-process'; import { TaskRunnerProcess } from './task-runner-process'; +import type { DisconnectErrorOptions } from './task-runner-types'; /** * Analyzes the disconnect reason of a task runner process to provide a more diff --git a/packages/cli/src/runners/node-process-oom-detector.ts b/packages/cli/src/task-runners/node-process-oom-detector.ts similarity index 100% rename from packages/cli/src/runners/node-process-oom-detector.ts rename to packages/cli/src/task-runners/node-process-oom-detector.ts diff --git a/packages/cli/src/runners/sliding-window-signal.ts b/packages/cli/src/task-runners/sliding-window-signal.ts similarity index 100% rename from packages/cli/src/runners/sliding-window-signal.ts rename to packages/cli/src/task-runners/sliding-window-signal.ts diff --git a/packages/cli/src/runners/task-broker.service.ts b/packages/cli/src/task-runners/task-broker.service.ts similarity index 98% rename from packages/cli/src/runners/task-broker.service.ts rename to packages/cli/src/task-runners/task-broker.service.ts index e52992d38eed8..86c39b54e16c3 100644 --- a/packages/cli/src/runners/task-broker.service.ts +++ b/packages/cli/src/task-runners/task-broker.service.ts @@ -5,17 +5,17 @@ import type { RunnerMessage, TaskResultData, } from '@n8n/task-runner'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { Service } from 'typedi'; import config from '@/config'; import { Time } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { TaskDeferredError, TaskRejectError } from './errors'; import { TaskRunnerTimeoutError } from './errors/task-runner-timeout.error'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; export interface TaskRunner { id: string; @@ -89,7 +89,7 @@ export class TaskBroker { constructor( private readonly logger: Logger, private readonly taskRunnersConfig: TaskRunnersConfig, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly taskRunnerLifecycleEvents: TaskRunnerLifecycleEvents, ) { if (this.taskRunnersConfig.taskTimeout <= 0) { throw new ApplicationError('Task timeout must be greater than 0'); @@ -460,7 +460,7 @@ export class TaskBroker { if (!task) return; if (this.taskRunnersConfig.mode === 'internal') { - this.runnerLifecycleEvents.emit('runner:timed-out-during-task'); + this.taskRunnerLifecycleEvents.emit('runner:timed-out-during-task'); } else if (this.taskRunnersConfig.mode === 'external') { await this.messageRunner(task.runnerId, { type: 'broker:taskcancel', diff --git a/packages/cli/src/runners/task-managers/__tests__/data-request-response-builder.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts similarity index 100% rename from packages/cli/src/runners/task-managers/__tests__/data-request-response-builder.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts diff --git a/packages/cli/src/runners/task-managers/__tests__/data-request-response-stripper.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts similarity index 100% rename from packages/cli/src/runners/task-managers/__tests__/data-request-response-stripper.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts diff --git a/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts similarity index 91% rename from packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts index 84584e05df27b..3066f25cfd0d0 100644 --- a/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts @@ -2,10 +2,10 @@ import { mock } from 'jest-mock-extended'; import { get, set } from 'lodash'; import type { NodeTypes } from '@/node-types'; -import type { Task } from '@/runners/task-managers/task-manager'; -import { TaskManager } from '@/runners/task-managers/task-manager'; +import type { Task } from '@/task-runners/task-managers/task-requester'; +import { TaskRequester } from '@/task-runners/task-managers/task-requester'; -class TestTaskManager extends TaskManager { +class TestTaskRequester extends TaskRequester { sentMessages: unknown[] = []; sendMessage(message: unknown) { @@ -13,12 +13,12 @@ class TestTaskManager extends TaskManager { } } -describe('TaskManager', () => { - let instance: TestTaskManager; +describe('TaskRequester', () => { + let instance: TestTaskRequester; const mockNodeTypes = mock(); beforeEach(() => { - instance = new TestTaskManager(mockNodeTypes); + instance = new TestTaskRequester(mockNodeTypes); }); describe('handleRpc', () => { diff --git a/packages/cli/src/runners/task-managers/data-request-response-builder.ts b/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts similarity index 100% rename from packages/cli/src/runners/task-managers/data-request-response-builder.ts rename to packages/cli/src/task-runners/task-managers/data-request-response-builder.ts diff --git a/packages/cli/src/runners/task-managers/data-request-response-stripper.ts b/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts similarity index 100% rename from packages/cli/src/runners/task-managers/data-request-response-stripper.ts rename to packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts diff --git a/packages/cli/src/runners/task-managers/local-task-manager.ts b/packages/cli/src/task-runners/task-managers/local-task-requester.ts similarity index 83% rename from packages/cli/src/runners/task-managers/local-task-manager.ts rename to packages/cli/src/task-runners/task-managers/local-task-requester.ts index 7d898aaebea38..0aa83c4a6f2a2 100644 --- a/packages/cli/src/runners/task-managers/local-task-manager.ts +++ b/packages/cli/src/task-runners/task-managers/local-task-requester.ts @@ -3,15 +3,15 @@ import Container, { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; -import { TaskManager } from './task-manager'; +import { TaskRequester } from './task-requester'; import type { RequesterMessageCallback } from '../task-broker.service'; import { TaskBroker } from '../task-broker.service'; @Service() -export class LocalTaskManager extends TaskManager { +export class LocalTaskRequester extends TaskRequester { taskBroker: TaskBroker; - id: string = 'single-main'; + id = 'local-task-requester'; constructor(nodeTypes: NodeTypes) { super(nodeTypes); diff --git a/packages/cli/src/runners/task-managers/task-manager.ts b/packages/cli/src/task-runners/task-managers/task-requester.ts similarity index 99% rename from packages/cli/src/runners/task-managers/task-manager.ts rename to packages/cli/src/task-runners/task-managers/task-requester.ts index 44193f9377a17..52e75678e41be 100644 --- a/packages/cli/src/runners/task-managers/task-manager.ts +++ b/packages/cli/src/task-runners/task-managers/task-requester.ts @@ -49,7 +49,7 @@ interface ExecuteFunctionObject { } @Service() -export abstract class TaskManager { +export abstract class TaskRequester { requestAcceptRejects: Map = new Map(); taskAcceptRejects: Map = new Map(); diff --git a/packages/cli/src/runners/runner-lifecycle-events.ts b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts similarity index 58% rename from packages/cli/src/runners/runner-lifecycle-events.ts rename to packages/cli/src/task-runners/task-runner-lifecycle-events.ts index 8ea2da38b183d..d513b1583df89 100644 --- a/packages/cli/src/runners/runner-lifecycle-events.ts +++ b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts @@ -2,10 +2,10 @@ import { Service } from 'typedi'; import { TypedEmitter } from '@/typed-emitter'; -type RunnerLifecycleEventMap = { +type TaskRunnerLifecycleEventMap = { 'runner:failed-heartbeat-check': never; 'runner:timed-out-during-task': never; }; @Service() -export class RunnerLifecycleEvents extends TypedEmitter {} +export class TaskRunnerLifecycleEvents extends TypedEmitter {} diff --git a/packages/cli/src/runners/task-runner-module.ts b/packages/cli/src/task-runners/task-runner-module.ts similarity index 73% rename from packages/cli/src/runners/task-runner-module.ts rename to packages/cli/src/task-runners/task-runner-module.ts index 434daa066a35c..0608b24972075 100644 --- a/packages/cli/src/runners/task-runner-module.ts +++ b/packages/cli/src/task-runners/task-runner-module.ts @@ -1,19 +1,18 @@ import { TaskRunnersConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import * as a from 'node:assert/strict'; import Container, { Service } from 'typedi'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; -import type { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import type { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import type { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import type { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; import { MissingAuthTokenError } from './errors/missing-auth-token.error'; -import { TaskRunnerWsServer } from './runner-ws-server'; -import type { LocalTaskManager } from './task-managers/local-task-manager'; +import type { LocalTaskRequester } from './task-managers/local-task-requester'; import type { TaskRunnerServer } from './task-runner-server'; +import { TaskRunnerWsServer } from './task-runner-ws-server'; /** * Module responsible for loading and starting task runner. Task runner can be @@ -26,7 +25,7 @@ export class TaskRunnerModule { private taskRunnerWsServer: TaskRunnerWsServer | undefined; - private taskManager: LocalTaskManager | undefined; + private taskRequester: LocalTaskRequester | undefined; private taskRunnerProcess: TaskRunnerProcess | undefined; @@ -47,7 +46,7 @@ export class TaskRunnerModule { if (mode === 'external' && !authToken) throw new MissingAuthTokenError(); - await this.loadTaskManager(); + await this.loadTaskRequester(); await this.loadTaskRunnerServer(); if (mode === 'internal') { @@ -74,17 +73,19 @@ export class TaskRunnerModule { await Promise.all([stopRunnerProcessTask, stopRunnerServerTask]); } - private async loadTaskManager() { - const { TaskManager } = await import('@/runners/task-managers/task-manager'); - const { LocalTaskManager } = await import('@/runners/task-managers/local-task-manager'); - this.taskManager = Container.get(LocalTaskManager); - Container.set(TaskManager, this.taskManager); + private async loadTaskRequester() { + const { TaskRequester } = await import('@/task-runners/task-managers/task-requester'); + const { LocalTaskRequester } = await import( + '@/task-runners/task-managers/local-task-requester' + ); + this.taskRequester = Container.get(LocalTaskRequester); + Container.set(TaskRequester, this.taskRequester); } private async loadTaskRunnerServer() { // These are imported dynamically because we need to set the task manager // instance before importing them - const { TaskRunnerServer } = await import('@/runners/task-runner-server'); + const { TaskRunnerServer } = await import('@/task-runners/task-runner-server'); this.taskRunnerHttpServer = Container.get(TaskRunnerServer); this.taskRunnerWsServer = Container.get(TaskRunnerWsServer); @@ -94,7 +95,7 @@ export class TaskRunnerModule { private async startInternalTaskRunner() { a.ok(this.taskRunnerWsServer, 'Task Runner WS Server not loaded'); - const { TaskRunnerProcess } = await import('@/runners/task-runner-process'); + const { TaskRunnerProcess } = await import('@/task-runners/task-runner-process'); this.taskRunnerProcess = Container.get(TaskRunnerProcess); this.taskRunnerProcessRestartLoopDetector = new TaskRunnerProcessRestartLoopDetector( this.taskRunnerProcess, @@ -107,7 +108,7 @@ export class TaskRunnerModule { await this.taskRunnerProcess.start(); const { InternalTaskRunnerDisconnectAnalyzer } = await import( - '@/runners/internal-task-runner-disconnect-analyzer' + '@/task-runners/internal-task-runner-disconnect-analyzer' ); this.taskRunnerWsServer.setDisconnectAnalyzer( Container.get(InternalTaskRunnerDisconnectAnalyzer), diff --git a/packages/cli/src/runners/task-runner-process-restart-loop-detector.ts b/packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts similarity index 90% rename from packages/cli/src/runners/task-runner-process-restart-loop-detector.ts rename to packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts index 5431cde195b0d..f816c97c00058 100644 --- a/packages/cli/src/runners/task-runner-process-restart-loop-detector.ts +++ b/packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts @@ -1,6 +1,6 @@ import { Time } from '@/constants'; -import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import type { TaskRunnerProcess } from '@/runners/task-runner-process'; +import { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import type { TaskRunnerProcess } from '@/task-runners/task-runner-process'; import { TypedEmitter } from '@/typed-emitter'; const MAX_RESTARTS = 5; diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/task-runners/task-runner-process.ts similarity index 96% rename from packages/cli/src/runners/task-runner-process.ts rename to packages/cli/src/task-runners/task-runner-process.ts index 2716383f17430..aa8c9e7615061 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/task-runners/task-runner-process.ts @@ -1,16 +1,16 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Logger } from 'n8n-core'; import * as a from 'node:assert/strict'; import { spawn } from 'node:child_process'; import * as process from 'node:process'; import { Service } from 'typedi'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { TaskRunnerAuthService } from './auth/task-runner-auth.service'; import { forwardToLogger } from './forward-to-logger'; import { NodeProcessOomDetector } from './node-process-oom-detector'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; import { TypedEmitter } from '../typed-emitter'; type ChildProcess = ReturnType; @@ -68,7 +68,7 @@ export class TaskRunnerProcess extends TypedEmitter { logger: Logger, private readonly runnerConfig: TaskRunnersConfig, private readonly authService: TaskRunnerAuthService, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly runnerLifecycleEvents: TaskRunnerLifecycleEvents, ) { super(); diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/task-runners/task-runner-server.ts similarity index 95% rename from packages/cli/src/runners/task-runner-server.ts rename to packages/cli/src/task-runners/task-runner-server.ts index 2b1f481b0e338..1e92916e3c0fb 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/task-runners/task-runner-server.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from '@n8n/config'; import compression from 'compression'; import express from 'express'; +import { Logger } from 'n8n-core'; import * as a from 'node:assert/strict'; import { randomBytes } from 'node:crypto'; import { ServerResponse, type Server, createServer as createHttpServer } from 'node:http'; @@ -10,15 +11,14 @@ import { Service } from 'typedi'; import { Server as WSServer } from 'ws'; import { inTest } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { bodyParser, rawBodyReader } from '@/middlewares'; import { send } from '@/response-helper'; -import { TaskRunnerAuthController } from '@/runners/auth/task-runner-auth.controller'; +import { TaskRunnerAuthController } from '@/task-runners/auth/task-runner-auth.controller'; import type { TaskRunnerServerInitRequest, TaskRunnerServerInitResponse, -} from '@/runners/runner-types'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; +} from '@/task-runners/task-runner-types'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; /** * Task Runner HTTP & WS server diff --git a/packages/cli/src/runners/runner-types.ts b/packages/cli/src/task-runners/task-runner-types.ts similarity index 100% rename from packages/cli/src/runners/runner-types.ts rename to packages/cli/src/task-runners/task-runner-types.ts diff --git a/packages/cli/src/runners/runner-ws-server.ts b/packages/cli/src/task-runners/task-runner-ws-server.ts similarity index 95% rename from packages/cli/src/runners/runner-ws-server.ts rename to packages/cli/src/task-runners/task-runner-ws-server.ts index 8ea3a7edbe547..dde666a584a7d 100644 --- a/packages/cli/src/runners/runner-ws-server.ts +++ b/packages/cli/src/task-runners/task-runner-ws-server.ts @@ -1,21 +1,21 @@ import { TaskRunnersConfig } from '@n8n/config'; import type { BrokerMessage, RunnerMessage } from '@n8n/task-runner'; +import { Logger } from 'n8n-core'; import { ApplicationError, jsonStringify } from 'n8n-workflow'; import { Service } from 'typedi'; import type WebSocket from 'ws'; import { Time, WsStatusCodes } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; import type { DisconnectAnalyzer, DisconnectReason, TaskRunnerServerInitRequest, TaskRunnerServerInitResponse, -} from './runner-types'; -import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service'; +} from './task-runner-types'; function heartbeat(this: WebSocket) { this.isAlive = true; @@ -34,7 +34,7 @@ export class TaskRunnerWsServer { private readonly taskBroker: TaskBroker, private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer, private readonly taskTunnersConfig: TaskRunnersConfig, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly runnerLifecycleEvents: TaskRunnerLifecycleEvents, ) {} start() { diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index a8d39d898ee8a..051ae4cf258a6 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; import type RudderStack from '@rudderstack/rudder-sdk-node'; import axios from 'axios'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import type { ITelemetryTrackProperties } from 'n8n-workflow'; import { Container, Service } from 'typedi'; @@ -13,10 +13,9 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { OnShutdown } from '@/decorators/on-shutdown'; import type { IExecutionTrackProperties } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { SourceControlPreferencesService } from '../environments/source-control/source-control-preferences.service.ee'; +import { SourceControlPreferencesService } from '../environments.ee/source-control/source-control-preferences.service.ee'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; diff --git a/packages/cli/src/user-management/email/node-mailer.ts b/packages/cli/src/user-management/email/node-mailer.ts index a35ab77318456..4f4dc4f8953a8 100644 --- a/packages/cli/src/user-management/email/node-mailer.ts +++ b/packages/cli/src/user-management/email/node-mailer.ts @@ -1,14 +1,12 @@ import { GlobalConfig } from '@n8n/config'; import { pick } from 'lodash'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import path from 'node:path'; import type { Transporter } from 'nodemailer'; import { createTransport } from 'nodemailer'; import type SMTPConnection from 'nodemailer/lib/smtp-connection'; import { Service } from 'typedi'; -import { Logger } from '@/logging/logger.service'; - import type { MailData, SendEmailResult } from './interfaces'; @Service() diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 3acddad185589..25794cec8d6dc 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import Handlebars from 'handlebars'; +import { Logger } from 'n8n-core'; import { join as pathJoin } from 'path'; import { Container, Service } from 'typedi'; @@ -11,7 +12,6 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; import { toError } from '@/utils'; diff --git a/packages/cli/src/user-management/permission-checker.ts b/packages/cli/src/user-management/permission-checker.ts index c93d2acf91cb4..51a6e8c6a3bc1 100644 --- a/packages/cli/src/user-management/permission-checker.ts +++ b/packages/cli/src/user-management/permission-checker.ts @@ -4,7 +4,7 @@ import { Service } from 'typedi'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; @Service() export class PermissionChecker { diff --git a/packages/cli/src/wait-tracker.ts b/packages/cli/src/wait-tracker.ts index f42905ace1978..02480110ae9f2 100644 --- a/packages/cli/src/wait-tracker.ts +++ b/packages/cli/src/wait-tracker.ts @@ -1,10 +1,9 @@ -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowRunner } from '@/workflow-runner'; diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 6d6fc9161de2c..1ebfef347033d 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -1,4 +1,5 @@ import type { Response } from 'express'; +import { Logger } from 'n8n-core'; import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -6,7 +7,6 @@ import { Service } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index b90b1db59d985..ff5d47fd509bb 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -154,11 +154,7 @@ export class TestWebhooks implements IWebhookManager { * the webhook. If so, after the test webhook has been successfully executed, * the handler process commands the creator process to clear its test webhooks. */ - if ( - this.instanceSettings.isMultiMain && - pushRef && - !this.push.getBackend().hasPushRef(pushRef) - ) { + if (this.instanceSettings.isMultiMain && pushRef && !this.push.hasPushRef(pushRef)) { void this.publisher.publishCommand({ command: 'clear-test-webhooks', payload: { webhookKey: key, workflowEntity, pushRef }, diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 63557091893b0..ed2f8404bcaac 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,4 +1,5 @@ import type express from 'express'; +import { Logger } from 'n8n-core'; import { FORM_NODE_TYPE, type INodes, @@ -13,7 +14,6 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IExecutionResponse, IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 665708988171c..0566e72a10c16 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -9,7 +9,7 @@ import { GlobalConfig } from '@n8n/config'; import type express from 'express'; import get from 'lodash/get'; -import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { BinaryDataService, ErrorReporter, Logger } from 'n8n-core'; import type { IBinaryData, IBinaryKeyData, @@ -37,16 +37,17 @@ import { FORM_NODE_TYPE, NodeOperationError, } from 'n8n-workflow'; +import assert from 'node:assert'; import { finished } from 'stream/promises'; import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; +import config from '@/config'; import type { Project } from '@/databases/entities/project'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import type { IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { parseBody } from '@/middlewares'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; @@ -532,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 executionId = await Container.get(WorkflowRunner).run( runData, diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index 80b12b04cd897..f571c9450bedc 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -1,4 +1,4 @@ -import { HookContext, WebhookContext } from 'n8n-core'; +import { HookContext, WebhookContext, Logger } from 'n8n-core'; import { ApplicationError, Node, NodeHelpers } from 'n8n-workflow'; import type { IHttpRequestMethods, @@ -16,7 +16,6 @@ import { Service } from 'typedi'; import type { WebhookEntity } from '@/databases/entities/webhook-entity'; import { WebhookRepository } from '@/databases/repositories/webhook.repository'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 29c8d67502f80..ac2dab4d88dea 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -5,7 +5,13 @@ import type { PushMessage, PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { stringify } from 'flatted'; -import { ErrorReporter, WorkflowExecute, isObjectLiteral } from 'n8n-core'; +import { + ErrorReporter, + Logger, + InstanceSettings, + WorkflowExecute, + isObjectLiteral, +} from 'n8n-core'; import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; import type { IDataObject, @@ -58,12 +64,11 @@ import { updateExistingExecution, } from './execution-lifecycle-hooks/shared/shared-hook-functions'; import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings'; -import { Logger } from './logging/logger.service'; -import { TaskManager } from './runners/task-managers/task-manager'; -import { SecretsHelper } from './secrets-helpers'; +import { SecretsHelper } from './secrets-helpers.ee'; import { OwnershipService } from './services/ownership.service'; import { UrlService } from './services/url.service'; import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service'; +import { TaskRequester } from './task-runners/task-managers/task-requester'; import { PermissionChecker } from './user-management/permission-checker'; import { WorkflowExecutionService } from './workflows/workflow-execution.service'; import { WorkflowStaticDataService } from './workflows/workflow-static-data.service'; @@ -1016,7 +1021,7 @@ export async function getBase( setExecutionStatus, variables, secretsHelpers: Container.get(SecretsHelper), - async startAgentJob( + async startRunnerTask( additionalData: IWorkflowExecuteAdditionalData, jobType: string, settings: unknown, @@ -1034,7 +1039,7 @@ export async function getBase( envProviderState: EnvProviderState, executeData?: IExecuteData, ) { - return await Container.get(TaskManager).startTask( + return await Container.get(TaskRequester).startTask( additionalData, jobType, settings, @@ -1077,8 +1082,7 @@ function getWorkflowHooksIntegrated( } /** - * Returns WorkflowHooks instance for running integrated workflows - * (Workflows which get started inside of another workflow) + * Returns WorkflowHooks instance for worker in scaling mode. */ export function getWorkflowHooksWorkerExecuter( mode: WorkflowExecuteMode, @@ -1094,6 +1098,17 @@ export function getWorkflowHooksWorkerExecuter( 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); } diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index addae4e290b7e..b49e1ae556478 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -14,7 +14,7 @@ import { v4 as uuid } from 'uuid'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; export function generateFailedExecutionFromError( mode: WorkflowExecuteMode, diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 973d512e62079..1b31feb7c7969 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; +import { ErrorReporter, InstanceSettings, Logger, WorkflowExecute } from 'n8n-core'; import type { ExecutionError, IDeferredPromise, @@ -21,7 +21,6 @@ import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import type { ScalingService } from '@/scaling/scaling.service'; import type { Job, JobData } from '@/scaling/scaling.types'; @@ -67,10 +66,15 @@ export class WorkflowRunner { // // FIXME: This is a quick fix. The proper fix would be to not remove // the execution from the active executions while it's still running. - if (error instanceof ExecutionNotFoundError) { + if ( + error instanceof ExecutionNotFoundError || + error instanceof ExecutionCancelledError || + error.message.includes('cancelled') + ) { return; } + this.logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`); this.errorReporter.error(error, { executionId }); const isQueueMode = config.getEnv('executions.mode') === 'queue'; @@ -78,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 // 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, { includeData: false, }); @@ -149,9 +153,13 @@ export class WorkflowRunner { this.activeExecutions.attachResponsePromise(executionId, responsePromise); } - if (this.executionsMode === 'queue' && data.executionMode !== 'manual') { - // Do not run "manual" executions in bull because sending events to the - // frontend would not be possible + // @TODO: Reduce to true branch once feature is stable + const shouldEnqueue = + 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); } else { await this.runMainProcess(executionId, data, loadStaticData, restartExecutionId); @@ -345,6 +353,7 @@ export class WorkflowRunner { const jobData: JobData = { executionId, loadStaticData: !!loadStaticData, + pushRef: data.pushRef, }; if (!this.scalingService) { @@ -414,7 +423,6 @@ export class WorkflowRunner { data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined }, ); - this.logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`); await this.processError(error, new Date(), data.executionMode, executionId, hooks); reject(error); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 27b673c245f66..10d882121c8d2 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IDeferredPromise, IExecuteData, @@ -15,12 +15,12 @@ import type { import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; +import config from '@/config'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { CreateExecutionPayload, IWorkflowDb, IWorkflowErrorData } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; import { TestWebhooks } from '@/webhooks/test-webhooks'; @@ -147,6 +147,35 @@ export class WorkflowExecutionService { 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]; if (pinnedTrigger && !hasRunData(pinnedTrigger)) { diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts similarity index 97% rename from packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts rename to packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts index ce3927f73066b..70e00d2c6ded5 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts @@ -1,6 +1,6 @@ import config from '@/config'; import { License } from '@/license'; -import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history/workflow-history-helper.ee'; +import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; import { mockInstance } from '@test/mocking'; let licensePruneTime = -1; diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts similarity index 96% rename from packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts rename to packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts index a2a48587f006b..b80b38eb9e963 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts @@ -3,7 +3,7 @@ import { mockClear } from 'jest-mock-extended'; import { User } from '@/databases/entities/user'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { mockInstance, mockLogger } from '@test/mocking'; import { getWorkflow } from '@test-integration/workflow'; @@ -24,7 +24,7 @@ const testUser = Object.assign(new User(), { }); let isWorkflowHistoryEnabled = true; -jest.mock('@/workflows/workflow-history/workflow-history-helper.ee', () => { +jest.mock('@/workflows/workflow-history.ee/workflow-history-helper.ee', () => { return { isWorkflowHistoryEnabled: jest.fn(() => isWorkflowHistoryEnabled), }; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-helper.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts similarity index 100% rename from packages/cli/src/workflows/workflow-history/workflow-history-helper.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts similarity index 100% rename from packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts similarity index 100% rename from packages/cli/src/workflows/workflow-history/workflow-history.controller.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts similarity index 98% rename from packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts index 3b171e34227c4..2e23a7d64a3e0 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts +++ b/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts @@ -1,3 +1,4 @@ +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -8,7 +9,6 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowHistoryEnabled } from './workflow-history-helper.ee'; diff --git a/packages/cli/src/workflows/workflow-static-data.service.ts b/packages/cli/src/workflows/workflow-static-data.service.ts index 3e5159dc9a438..aaff18f319262 100644 --- a/packages/cli/src/workflows/workflow-static-data.service.ts +++ b/packages/cli/src/workflows/workflow-static-data.service.ts @@ -1,10 +1,9 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IDataObject, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowIdValid } from '@/utils'; @Service() diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 90a8af90b1678..830a3d2f98331 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type EntityManager } from '@n8n/typeorm'; import omit from 'lodash/omit'; +import { Logger } from 'n8n-core'; import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -17,9 +18,8 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error'; -import { Logger } from '@/logging/logger.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import type { WorkflowWithSharingsAndCredentials, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 7220e1a640cad..21f792747e290 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -5,7 +5,7 @@ import type { EntityManager } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; -import { BinaryDataService } from 'n8n-core'; +import { BinaryDataService, Logger } from 'n8n-core'; import { NodeApiError } from 'n8n-workflow'; import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -24,16 +24,15 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; -import { Logger } from '@/logging/logger.service'; import { hasSharing, type ListQuery } from '@/requests'; import { OrchestrationService } from '@/services/orchestration.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; import * as WorkflowHelpers from '@/workflow-helpers'; -import { WorkflowHistoryService } from './workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowSharingService } from './workflow-sharing.service'; @Service() diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 24765b422a2c1..b12dfdce5a4fe 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -3,6 +3,7 @@ import { GlobalConfig } from '@n8n/config'; import { In, type FindOptionsRelations } from '@n8n/typeorm'; import axios from 'axios'; import express from 'express'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; @@ -27,18 +28,17 @@ import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { IWorkflowResponse } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; import * as ResponseHelper from '@/response-helper'; import { NamingService } from '@/services/naming.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { TagService } from '@/services/tag.service'; import { UserManagementMailer } from '@/user-management/email'; import * as utils from '@/utils'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowExecutionService } from './workflow-execution.service'; -import { WorkflowHistoryService } from './workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowRequest } from './workflow.request'; import { WorkflowService } from './workflow.service'; import { EnterpriseWorkflowService } from './workflow.service.ee'; diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index a3e4f657f26ed..f965efe709f62 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,4 +1,5 @@ import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import { NodeApiError, Workflow } from 'n8n-workflow'; import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow'; import { Container } from 'typedi'; @@ -9,10 +10,9 @@ import type { WebhookEntity } from '@/databases/entities/webhook-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/test/integration/ai/ai.api.test.ts b/packages/cli/test/integration/ai/ai.api.test.ts new file mode 100644 index 0000000000000..34255dc72f55d --- /dev/null +++ b/packages/cli/test/integration/ai/ai.api.test.ts @@ -0,0 +1,99 @@ +import { randomUUID } from 'crypto'; +import { mock } from 'jest-mock-extended'; +import { Container } from 'typedi'; + +import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants'; +import type { Project } from '@/databases/entities/project'; +import type { User } from '@/databases/entities/user'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { AiService } from '@/services/ai.service'; + +import { createOwner } from '../shared/db/users'; +import * as testDb from '../shared/test-db'; +import type { SuperAgentTest } from '../shared/types'; +import { setupTestServer } from '../shared/utils'; + +const createAiCreditsResponse = { + apiKey: randomUUID(), + url: 'https://api.openai.com', +}; + +Container.set( + AiService, + mock({ + createFreeAiCredits: async () => createAiCreditsResponse, + }), +); + +const testServer = setupTestServer({ endpointGroups: ['ai'] }); + +let owner: User; +let ownerPersonalProject: Project; + +let authOwnerAgent: SuperAgentTest; + +beforeEach(async () => { + await testDb.truncate(['SharedCredentials', 'Credentials']); + + owner = await createOwner(); + + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + authOwnerAgent = testServer.authAgentFor(owner); +}); + +describe('POST /ai/free-credits', () => { + test('should create OpenAI managed credential', async () => { + // Act + const response = await authOwnerAgent.post('/ai/free-credits').send({ + projectId: ownerPersonalProject.id, + }); + + // Assert + + expect(response.statusCode).toBe(200); + + const { id, name, type, data: encryptedData, scopes } = response.body.data; + + expect(name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(type).toBe(OPEN_AI_API_CREDENTIAL_TYPE); + expect(encryptedData).not.toBe(createAiCreditsResponse); + + expect(scopes).toEqual( + [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ].sort(), + ); + + const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); + + expect(credential.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(credential.type).toBe(OPEN_AI_API_CREDENTIAL_TYPE); + expect(credential.data).not.toBe(createAiCreditsResponse); + expect(credential.isManaged).toBe(true); + + const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ + relations: { project: true, credentials: true }, + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.project.id).toBe(ownerPersonalProject.id); + expect(sharedCredential.credentials.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(sharedCredential.credentials.isManaged).toBe(true); + + const user = await Container.get(UserRepository).findOneOrFail({ where: { id: owner.id } }); + + expect(user.settings?.userClaimedAiCredits).toBe(true); + }); +}); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 6c1ddc5892011..4fa2a7145cbce 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -147,6 +147,21 @@ describe('POST /login', () => { const response = await testServer.authAgentFor(ownerUser).get('/login'); expect(response.statusCode).toBe(200); }); + + test('should fail on invalid email in the payload', async () => { + const response = await testServer.authlessAgent.post('/login').send({ + email: 'invalid-email', + password: ownerPassword, + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + validation: 'email', + code: 'invalid_string', + message: 'Invalid email', + path: ['email'], + }); + }); }); describe('GET /login', () => { diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts index ab7a8314b3a55..e6951644fde03 100644 --- a/packages/cli/test/integration/collaboration/collaboration.service.test.ts +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -16,7 +16,7 @@ import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/wor import * as testDb from '@test-integration/test-db'; describe('CollaborationService', () => { - mockInstance(Push, new Push(mock(), mock())); + mockInstance(Push, new Push(mock(), mock(), mock())); let pushService: Push; let collaborationService: CollaborationService; let owner: User; diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts index ef0ab2c0d6e60..a199310f9c652 100644 --- a/packages/cli/test/integration/commands/ldap/reset.test.ts +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -7,8 +7,8 @@ import { CredentialsRepository } from '@/databases/repositories/credentials.repo import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { getLdapSynchronizations, saveLdapSynchronization } from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; +import { getLdapSynchronizations, saveLdapSynchronization } from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Push } from '@/push'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index e17a8d2279165..bbcb15dfa07f4 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -9,16 +9,16 @@ import config from '@/config'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { ExternalHooks } from '@/external-hooks'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Push } from '@/push'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import { ScalingService } from '@/scaling/scaling.service'; import { OrchestrationService } from '@/services/orchestration.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; import { Telemetry } from '@/telemetry'; import { setupTestCommand } from '@test-integration/utils/test-command'; diff --git a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts index 8f7436fc7598a..82e97a11f6b26 100644 --- a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts @@ -3,16 +3,21 @@ import type { INodeListSearchResult, IWorkflowExecuteAdditionalData, ResourceMapperFields, + NodeParameterValueType, } from 'n8n-workflow'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; +import { mockInstance } from '@test/mocking'; import { createOwner } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { setupTestServer } from '../shared/utils'; describe('DynamicNodeParametersController', () => { + const additionalData = mock(); + const service = mockInstance(DynamicNodeParametersService); + const testServer = setupTestServer({ endpointGroups: ['dynamic-node-parameters'] }); let ownerAgent: SuperAgentTest; @@ -21,62 +26,171 @@ describe('DynamicNodeParametersController', () => { ownerAgent = testServer.authAgentFor(owner); }); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(additionalData); + }); + const commonRequestParams = { credentials: {}, currentNodeParameters: {}, - nodeTypeAndVersion: {}, + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, path: 'path', - methodName: 'methodName', }; describe('POST /dynamic-node-parameters/options', () => { - jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(mock()); - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getOptionsViaMethodName') - .mockResolvedValue([]); + service.getOptionsViaMethodName.mockResolvedValue([]); await ownerAgent .post('/dynamic-node-parameters/options') .send({ ...commonRequestParams, - loadOptions: {}, + methodName: 'testMethod', + }) + .expect(200); + }); + + it('should take params with loadOptions', async () => { + const expectedResult = [{ name: 'Test Option', value: 'test' }]; + service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/options') + .send({ + ...commonRequestParams, + loadOptions: { type: 'test' }, + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return empty array when no method or loadOptions provided', async () => { + const response = await ownerAgent + .post('/dynamic-node-parameters/options') + .send({ + ...commonRequestParams, }) .expect(200); + + expect(response.body).toEqual({ data: [] }); }); }); describe('POST /dynamic-node-parameters/resource-locator-results', () => { - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getResourceLocatorResults') - .mockResolvedValue(mock()); + it('should return resource locator results', async () => { + const expectedResult: INodeListSearchResult = { results: [] }; + service.getResourceLocatorResults.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/resource-locator-results') + .send({ + ...commonRequestParams, + methodName: 'testMethod', + filter: 'testFilter', + paginationToken: 'testToken', + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should handle resource locator results without pagination', async () => { + const mockResults = mock(); + service.getResourceLocatorResults.mockResolvedValue(mockResults); await ownerAgent .post('/dynamic-node-parameters/resource-locator-results') .send({ + methodName: 'testMethod', ...commonRequestParams, - filter: 'filter', - paginationToken: 'paginationToken', }) .expect(200); }); + + it('should return a 400 if methodName is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/resource-locator-results') + .send(commonRequestParams) + .expect(400); + }); }); describe('POST /dynamic-node-parameters/resource-mapper-fields', () => { - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getResourceMappingFields') - .mockResolvedValue(mock()); + it('should return resource mapper fields', async () => { + const expectedResult: ResourceMapperFields = { fields: [] }; + service.getResourceMappingFields.mockResolvedValue(expectedResult); + const response = await ownerAgent + .post('/dynamic-node-parameters/resource-mapper-fields') + .send({ + ...commonRequestParams, + methodName: 'testMethod', + loadOptions: 'testLoadOptions', + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if methodName is not defined', async () => { await ownerAgent .post('/dynamic-node-parameters/resource-mapper-fields') + .send(commonRequestParams) + .expect(400); + }); + }); + + describe('POST /dynamic-node-parameters/local-resource-mapper-fields', () => { + it('should return local resource mapper fields', async () => { + const expectedResult: ResourceMapperFields = { fields: [] }; + service.getLocalResourceMappingFields.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/local-resource-mapper-fields') .send({ ...commonRequestParams, - loadOptions: 'loadOptions', + methodName: 'testMethod', }) .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if methodName is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/local-resource-mapper-fields') + .send(commonRequestParams) + .expect(400); + }); + }); + + describe('POST /dynamic-node-parameters/action-result', () => { + it('should return action result with handler', async () => { + const expectedResult: NodeParameterValueType = { test: true }; + service.getActionResult.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/action-result') + .send({ + ...commonRequestParams, + handler: 'testHandler', + payload: { someData: 'test' }, + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if handler is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/action-result') + .send({ + ...commonRequestParams, + payload: { someData: 'test' }, + }) + .expect(400); }); }); }); diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 5428cafbd4dd2..962f5591d5708 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -8,7 +8,7 @@ import type { User } from '@/databases/entities/user'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import type { ListQuery } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { UserManagementMailer } from '@/user-management/email'; import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; @@ -540,6 +540,7 @@ describe('GET /credentials/:id', () => { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), type: ownerPersonalProject.type, + icon: null, }); expect(firstCredential.sharedWithProjects).toHaveLength(0); @@ -629,17 +630,20 @@ describe('GET /credentials/:id', () => { homeProject: { id: member1PersonalProject.id, name: member1.createPersonalProjectName(), + icon: null, type: 'personal', }, sharedWithProjects: expect.arrayContaining([ { id: member2PersonalProject.id, name: member2.createPersonalProjectName(), + icon: null, type: member2PersonalProject.type, }, { id: member3PersonalProject.id, name: member3.createPersonalProjectName(), + icon: null, type: member3PersonalProject.type, }, ]), diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 5f40850e599f7..2b3ee73b582fa 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -87,6 +87,7 @@ describe('GET /credentials', () => { validateMainCredentialData(credential); expect('data' in credential).toBe(false); expect(savedCredentialsIds).toContain(credential.id); + expect('isManaged' in credential).toBe(true); }); }); @@ -225,6 +226,161 @@ describe('GET /credentials', () => { } }); + test('should return data when ?includeData=true', async () => { + // ARRANGE + const [actor, otherMember] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProjectViewer = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectViewer, 'project:viewer'); + const teamProjectEditor = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectEditor, 'project:editor'); + + const [ + // should have data + ownedCredential, + // should not have + sharedCredential, + // should not have data + teamCredentialAsViewer, + // should have data + teamCredentialAsEditor, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: actor, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: otherMember, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: teamProjectEditor, + role: 'credential:owner', + }), + ]); + await shareCredentialWithUsers(sharedCredential, [actor]); + + // ACT + const response = await testServer + .authAgentFor(actor) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + const teamCredAsEditor = creds.find((c) => c.id === teamCredentialAsEditor.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).not.toBeDefined(); + expect(sharedCred.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).not.toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsEditor.id).toBe(teamCredentialAsEditor.id); + expect(teamCredAsEditor.data).toBeDefined(); + expect(teamCredAsEditor.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete'].sort(), + ); + }); + + test('should return data when ?includeData=true for owners', async () => { + // ARRANGE + const teamProjectViewer = await createTeamProject(undefined); + + const [ + // should have data + ownedCredential, + // should have data + sharedCredential, + // should have data + teamCredentialAsViewer, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + ]); + + // ACT + const response = await testServer + .authAgentFor(owner) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).toBeDefined(); + expect(sharedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + }); + describe('should return', () => { test('all credentials for owner', async () => { const { id: id1 } = await saveCredential(payload(), { @@ -1035,6 +1191,19 @@ describe('PATCH /credentials/:id', () => { expect(response.statusCode).toBe(403); }); + + test('should fail with a 400 is credential is managed', async () => { + const { id } = await saveCredential(randomCredentialPayload({ isManaged: true }), { + user: owner, + role: 'credential:owner', + }); + + const response = await authOwnerAgent + .patch(`/credentials/${id}`) + .send(randomCredentialPayload()); + + expect(response.statusCode).toBe(400); + }); }); describe('GET /credentials/new', () => { @@ -1188,10 +1357,11 @@ const INVALID_PAYLOADS = [ ]; function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWithProjects, homeProject } = credential; + const { name, type, sharedWithProjects, homeProject, isManaged } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); + expect(typeof isManaged).toBe('boolean'); if (sharedWithProjects) { expect(Array.isArray(sharedWithProjects)).toBe(true); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 4d2a3d668a6e6..3faa84b6759c5 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -9,9 +9,9 @@ import Container from 'typedi'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { SourceControlImportService } from '@/environments/source-control/source-control-import.service.ee'; -import type { ExportableCredential } from '@/environments/source-control/types/exportable-credential'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee'; +import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { mockInstance } from '../../shared/mocking'; import { saveCredential } from '../shared/db/credentials'; diff --git a/packages/cli/test/integration/environments/source-control.test.ts b/packages/cli/test/integration/environments/source-control.test.ts index f983b899aaf76..7e474e1f9adb6 100644 --- a/packages/cli/test/integration/environments/source-control.test.ts +++ b/packages/cli/test/integration/environments/source-control.test.ts @@ -2,9 +2,9 @@ import { Container } from 'typedi'; import config from '@/config'; import type { User } from '@/databases/entities/user'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/test/integration/evaluation/test-definitions.api.test.ts b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts index fe977fbfd3e98..8dd778289d638 100644 --- a/packages/cli/test/integration/evaluation/test-definitions.api.test.ts +++ b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts @@ -5,7 +5,7 @@ import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-en import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; -import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { createAnnotationTags } from '@test-integration/db/executions'; import { createUserShell } from './../shared/db/users'; diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index c36340108ec8c..7e01941c80329 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -7,8 +7,8 @@ import config from '@/config'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import type { EventService } from '@/events/event.service'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; -import { ExternalSecretsProviders } from '@/external-secrets/external-secrets-providers.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; +import { ExternalSecretsProviders } from '@/external-secrets.ee/external-secrets-providers.ee'; import type { ExternalSecretsSettings, SecretsProviderState } from '@/interfaces'; import { License } from '@/license'; diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 17573f49f59bb..884f72315ceb4 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -7,10 +7,13 @@ import config from '@/config'; import type { User } from '@/databases/entities/user'; import { AuthProviderSyncHistoryRepository } from '@/databases/repositories/auth-provider-sync-history.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { LDAP_DEFAULT_CONFIGURATION } from '@/ldap/constants'; -import { saveLdapSynchronization } from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; -import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { LDAP_DEFAULT_CONFIGURATION } from '@/ldap.ee/constants'; +import { saveLdapSynchronization } from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; +import { + getCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; import { getPersonalProject } from '../shared/db/projects'; diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 498be7abd53a0..f69d98a74fdcd 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -1,4 +1,4 @@ -import { randomInt, randomString } from 'n8n-workflow'; +import { randomString } from 'n8n-workflow'; import Container from 'typedi'; import { AuthService } from '@/auth/auth.service'; @@ -239,7 +239,7 @@ describe('Change password with MFA enabled', () => { .send({ password: newPassword, token: resetPasswordToken, - mfaCode: randomInt(10), + mfaCode: randomString(10), }) .expect(404); }); diff --git a/packages/cli/test/integration/password-reset.api.test.ts b/packages/cli/test/integration/password-reset.api.test.ts index 89d66c3f21b6e..2be4c0f0308f6 100644 --- a/packages/cli/test/integration/password-reset.api.test.ts +++ b/packages/cli/test/integration/password-reset.api.test.ts @@ -12,7 +12,7 @@ import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; import { JwtService } from '@/services/jwt.service'; import { PasswordUtility } from '@/services/password.utility'; -import { setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { setCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; import { createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 5d425d17eeffc..4c4ad6be5d479 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -1,7 +1,7 @@ import Container from 'typedi'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { linkUserToProject, createTeamProject } from './shared/db/projects'; import { createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index 4f34048a1a465..9d4103e84e802 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -21,7 +21,7 @@ import { mockInstance, mockLogger } from '../shared/mocking'; describe('softDeleteOnPruningCycle()', () => { let pruningService: PruningService; - const instanceSettings = new InstanceSettings(mock()); + const instanceSettings = Container.get(InstanceSettings); instanceSettings.markAsLeader(); const now = new Date(); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f815d9d07bf65..42833825580c0 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -131,6 +131,7 @@ describe('Projects in Public API', () => { expect(response.status).toBe(201); expect(response.body).toEqual({ name: 'some-project', + icon: null, type: 'team', id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 28f9d444daa27..51da846b5b03a 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -11,7 +11,7 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { ExecutionService } from '@/executions/execution.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { Telemetry } from '@/telemetry'; import { createTeamProject } from '@test-integration/db/projects'; diff --git a/packages/cli/test/integration/runners/task-runner-module.external.test.ts b/packages/cli/test/integration/runners/task-runner-module.external.test.ts index bb61dae6d476b..afb07c7e66926 100644 --- a/packages/cli/test/integration/runners/task-runner-module.external.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.external.test.ts @@ -2,11 +2,11 @@ import { TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import Container from 'typedi'; -import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error'; -import { TaskRunnerModule } from '@/runners/task-runner-module'; +import { MissingAuthTokenError } from '@/task-runners/errors/missing-auth-token.error'; +import { TaskRunnerModule } from '@/task-runners/task-runner-module'; -import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/runners/default-task-runner-disconnect-analyzer'; -import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server'; +import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/task-runners/default-task-runner-disconnect-analyzer'; +import { TaskRunnerWsServer } from '../../../src/task-runners/task-runner-ws-server'; describe('TaskRunnerModule in external mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); diff --git a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts index db195001a7803..0d4583517ec10 100644 --- a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts @@ -1,10 +1,10 @@ import { TaskRunnersConfig } from '@n8n/config'; import Container from 'typedi'; -import { TaskRunnerModule } from '@/runners/task-runner-module'; +import { TaskRunnerModule } from '@/task-runners/task-runner-module'; -import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/runners/internal-task-runner-disconnect-analyzer'; -import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server'; +import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/task-runners/internal-task-runner-disconnect-analyzer'; +import { TaskRunnerWsServer } from '../../../src/task-runners/task-runner-ws-server'; describe('TaskRunnerModule in internal mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); diff --git a/packages/cli/test/integration/runners/task-runner-process.test.ts b/packages/cli/test/integration/runners/task-runner-process.test.ts index b21ef68640e86..89aa80d0f4a52 100644 --- a/packages/cli/test/integration/runners/task-runner-process.test.ts +++ b/packages/cli/test/integration/runners/task-runner-process.test.ts @@ -1,9 +1,9 @@ import Container from 'typedi'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; -import { TaskBroker } from '@/runners/task-broker.service'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import { TaskBroker } from '@/task-runners/task-broker.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; import { retryUntil } from '@test-integration/retry-until'; import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server'; diff --git a/packages/cli/test/integration/saml/saml-helpers.test.ts b/packages/cli/test/integration/saml/saml-helpers.test.ts index 6ac48ee93b646..87d020248c2ff 100644 --- a/packages/cli/test/integration/saml/saml-helpers.test.ts +++ b/packages/cli/test/integration/saml/saml-helpers.test.ts @@ -1,5 +1,5 @@ -import * as helpers from '@/sso/saml/saml-helpers'; -import type { SamlUserAttributes } from '@/sso/saml/types/saml-user-attributes'; +import * as helpers from '@/sso.ee/saml/saml-helpers'; +import type { SamlUserAttributes } from '@/sso.ee/saml/types'; import { getPersonalProject } from '../shared/db/projects'; import * as testDb from '../shared/test-db'; diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index d30d57356a980..7737444c6b19c 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,6 +1,9 @@ import type { User } from '@/databases/entities/user'; -import { setSamlLoginEnabled } from '@/sso/saml/saml-helpers'; -import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers'; +import { + getCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { sampleConfig } from './sample-metadata'; import { createOwner, createUser } from '../shared/db/users'; @@ -31,6 +34,8 @@ beforeAll(async () => { authMemberAgent = testServer.authAgentFor(someUser); }); +beforeEach(async () => await enableSaml(false)); + describe('Instance owner', () => { describe('PATCH /me', () => { test('should succeed with valid inputs', async () => { @@ -86,6 +91,17 @@ describe('Instance owner', () => { .expect(200); 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', () => { diff --git a/packages/cli/test/integration/saml/sample-metadata.ts b/packages/cli/test/integration/saml/sample-metadata.ts index fd7968c2fbf62..528a3f158fd0a 100644 --- a/packages/cli/test/integration/saml/sample-metadata.ts +++ b/packages/cli/test/integration/saml/sample-metadata.ts @@ -1,7 +1,8 @@ +import type { SamlPreferences } from '@n8n/api-types'; export const sampleMetadata = '\n\n\n\n\n\n\n\n\n\nd/0TlU9d7qi9oQxDwjsZi69RMCiheKmcjJ7W0fRCHlM=\n\n\num+M46ZJmOhK1vGm6ZTIOY926ZN8pkMClyVprLs0NAWH3sEO11rZZZkcAnSuWrLR\n8BcrwpKRU6qE4zrZBWfh+/Fqp180OvUa7vUDpxuZFJZhv7dSldfLgAdFX2VHctBo\n77hdLmrmJuWv/u6Gzsie/J8/2D0U0OwDGwfsOLLW3rjrfea5opcaAxY+0Rh+2zzk\nzIxVBqtSnSKxAJtkOpCDzbtnQIO0meB0ZvO7ssxwSFjBbHs34TRj1S3GFgCZXzl5\naXDi7AoWEs1YPviRNb368OrD3aljFBK0gzjullFter0rzp2TzSzZilkxaZmhupJe\n388cIDBKJPUmkxumafWXxJIOMfktUTnciUl4kz0OfDQ0J5m5NaDrmvYU8g/2A0+P\nVRI88N9n0GcT9cDvzTCEDSBFefOVpvuQkue+ZYLpZ8bJJS0ykunkcNiXLbGlBlCS\nje3Od78eNjwzG/WYmHsf9ajmBezBrUmzvdJx+SmfGRZplu86z9NrOQMliKcU4/T6\nOGEwz0pRcvhMJLn+MNR2DPzX6YHnPZ0neyiUqnIkzt0fU4q1QNdcyqSTfRQlZjkx\ndbdLsEFALxcNRv8vFaAbsQpxPuFNlfZeyAWQ/MLoBG1rUiEl06I9REMN6KM7CTog\n5i926hP4LLsIki45Ob83glFOrIoj/3nAw2jbd2Crl+E=\n\n\nMIIFUzCCAzugAwIBAgIRAJ1peD6pO0pygujUcWb85QswDQYJKoZIhvcNAQELBQAw\nHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjMuMi4yMB4XDTIzMDIyNzEzMTQ0MFoX\nDTI0MDIyODEzMTQ0MFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVk\nIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYt\nc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3thve9UWPL09\nouGwUPlCxfrBDDKmDdvoMc3eahfuop2tSP38EvdBcnPCVYTtu2hhHNqN/QtoyAZc\nTvwD8oDjwiYxdO6VbNjMZAnMD4W84l2niGnG7ATy/niNcZoge4xy+OmCJKXsolbs\nXT+hQGQ2oiUDnbX8QwMQCMN8FBF+EvYoHXKvRjmjO75DHyHY9JP05HZTO3lycVLW\nGrIq4oJfp60PN/0z5tbpk/Tyst21o4lcESAM4fkmndonPmoKMr7q9g+CFYRT+As6\niB+L38J44YNWs0Qm42tHAlveinBRuLLMi+eMC2L0sckvyJKB1qHG+bKl7jVXNDJg\n5KWKEHdM4CBg3dJkign+12EO205ruLYSBydZErAb2NKd2htgYs/zGHSgb3LhQ3vE\nuHiTIcq828PWmVM7l3B8CJ+ZyPLixywT0pKgkb8lrDqzXIffRljCYMT2pIR4FNuy\n+CzXMYm+N30qVO8h9+cl3YRSHpFBk9KJ0/+HQp1k6ELnaYW+LryS8Jr1uPxhwyMq\nGu+4bxCF8JfZncojMhlQghXCQUvOaboNlBWv5jtsoZ9mN266V1EJpnF064UimQ1f\noN1O4l4292NvkChcmiQf2YDE5PrMWm10gQg401oulE9o91OsxLRmyw/qZTJvA06K\ngVamNLfhN/St/CVfl8q6ldgoHmWaxY8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJD\nT1BRVVpWNW1qdWFvQ01hdEVvenU5ajNoUnlhU0UyQThaTjd4WlZqUy5zZWxmLXNp\nZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAwaQtK4s2DnJx\njg6i6BSo/rhNg7ClXgnOyF79T7JO3gexVjzboY2UTi1ut/DEII01PI0qgQ62+q9l\nTloWd1SpxPOrOVeu2uVgTK0LkGb63q355iJ2myfhFYYPPprNDzvUhnX8cVY979Ma\niqAOCJW7irlHAH2bLAujanRdlcgFtmoe5lZ+qnS5iOUmp5tehPsDJGlPZ3nCWJcR\nQHDLLSOp3TvR5no8nj0cWxUWnNeaGoJy1GsJlGapLXS5pUKpxVg9GeEcQxjBkFgM\nLWrkWBsQDvC5+GlmHgSkdRvuYBlB6CRK2eGY7G06v7ZRPhf82LvEFRBwzJvGdM0g\n491OTTJquTN2wyq45UlJK4anMYrUbpi8p8MOW7IUw6a+SvZyJab9gNoLTUzA6Mlz\nQP9bPrEALpwNhmHsmD09zNyYiNfpkpLJog96wPscx4b+gsg+5PcilET8qvth6VYD\nup8TdsonPvDPH0oyo66SAYoyOgAeB+BHTicjtVt+UnrhXYj92BHDXfmfdTzA8QcY\n7reLPIOQVk1zV24cwySiLh4F2Hr8z8V1wMRVNVHcezMsVBvCzxQ15XlMq9X2wBuj\nfED93dXJVs+WuzbpTIoXvHHT3zWnzykX8hVbrj9ddzF8TuJW4NYis0cH5SLzvtPj\n7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=\n\n\nMIIFUzCCAzugAwIBAgIRAJ1peD6pO0pygujUcWb85QswDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjMuMi4yMB4XDTIzMDIyNzEzMTQ0MFoXDTI0MDIyODEzMTQ0MFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3thve9UWPL09ouGwUPlCxfrBDDKmDdvoMc3eahfuop2tSP38EvdBcnPCVYTtu2hhHNqN/QtoyAZcTvwD8oDjwiYxdO6VbNjMZAnMD4W84l2niGnG7ATy/niNcZoge4xy+OmCJKXsolbsXT+hQGQ2oiUDnbX8QwMQCMN8FBF+EvYoHXKvRjmjO75DHyHY9JP05HZTO3lycVLWGrIq4oJfp60PN/0z5tbpk/Tyst21o4lcESAM4fkmndonPmoKMr7q9g+CFYRT+As6iB+L38J44YNWs0Qm42tHAlveinBRuLLMi+eMC2L0sckvyJKB1qHG+bKl7jVXNDJg5KWKEHdM4CBg3dJkign+12EO205ruLYSBydZErAb2NKd2htgYs/zGHSgb3LhQ3vEuHiTIcq828PWmVM7l3B8CJ+ZyPLixywT0pKgkb8lrDqzXIffRljCYMT2pIR4FNuy+CzXMYm+N30qVO8h9+cl3YRSHpFBk9KJ0/+HQp1k6ELnaYW+LryS8Jr1uPxhwyMqGu+4bxCF8JfZncojMhlQghXCQUvOaboNlBWv5jtsoZ9mN266V1EJpnF064UimQ1foN1O4l4292NvkChcmiQf2YDE5PrMWm10gQg401oulE9o91OsxLRmyw/qZTJvA06KgVamNLfhN/St/CVfl8q6ldgoHmWaxY8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDT1BRVVpWNW1qdWFvQ01hdEVvenU5ajNoUnlhU0UyQThaTjd4WlZqUy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAwaQtK4s2DnJxjg6i6BSo/rhNg7ClXgnOyF79T7JO3gexVjzboY2UTi1ut/DEII01PI0qgQ62+q9lTloWd1SpxPOrOVeu2uVgTK0LkGb63q355iJ2myfhFYYPPprNDzvUhnX8cVY979MaiqAOCJW7irlHAH2bLAujanRdlcgFtmoe5lZ+qnS5iOUmp5tehPsDJGlPZ3nCWJcRQHDLLSOp3TvR5no8nj0cWxUWnNeaGoJy1GsJlGapLXS5pUKpxVg9GeEcQxjBkFgMLWrkWBsQDvC5+GlmHgSkdRvuYBlB6CRK2eGY7G06v7ZRPhf82LvEFRBwzJvGdM0g491OTTJquTN2wyq45UlJK4anMYrUbpi8p8MOW7IUw6a+SvZyJab9gNoLTUzA6MlzQP9bPrEALpwNhmHsmD09zNyYiNfpkpLJog96wPscx4b+gsg+5PcilET8qvth6VYDup8TdsonPvDPH0oyo66SAYoyOgAeB+BHTicjtVt+UnrhXYj92BHDXfmfdTzA8QcY7reLPIOQVk1zV24cwySiLh4F2Hr8z8V1wMRVNVHcezMsVBvCzxQ15XlMq9X2wBujfED93dXJVs+WuzbpTIoXvHHT3zWnzykX8hVbrj9ddzF8TuJW4NYis0cH5SLzvtPj7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectNameurn:oasis:names:tc:SAML:2.0:nameid-format:transient'; -export const sampleConfig = { +export const sampleConfig: SamlPreferences = { mapping: { email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', @@ -25,6 +26,5 @@ export const sampleConfig = { action: 'after', }, }, - entityID: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/metadata', - returnUrl: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/acs', + relayState: '', }; diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index 85393a4013278..b475bc83ebdd8 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -4,7 +4,7 @@ import Container from 'typedi'; import type { ProjectRole } from '@/databases/entities/project-relation'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { createMember } from '../shared/db/users'; import * as testDb from '../shared/test-db'; diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 5c86f1dc3534b..bc8099e494fed 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -40,6 +40,7 @@ export function newWorkflow(attributes: Partial = {}): WorkflowE ], connections: connections ?? {}, versionId: versionId ?? uuid(), + settings: {}, ...attributes, }); diff --git a/packages/cli/test/integration/shared/ldap.ts b/packages/cli/test/integration/shared/ldap.ts index 3f48cf2e3cfc5..9f87bd9962034 100644 --- a/packages/cli/test/integration/shared/ldap.ts +++ b/packages/cli/test/integration/shared/ldap.ts @@ -2,8 +2,8 @@ import { jsonParse } from 'n8n-workflow'; import Container from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; -import type { LdapConfig } from '@/ldap/types'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; +import type { LdapConfig } from '@/ldap.ee/types'; export const defaultLdapConfig = { ...LDAP_DEFAULT_CONFIGURATION, diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index a51fae3a051e8..e556c4f5129e4 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -37,10 +37,13 @@ const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); export const randomName = () => randomString(4, 8).toLowerCase(); -export const randomCredentialPayload = (): CredentialPayload => ({ +export const randomCredentialPayload = ({ + isManaged = false, +}: { isManaged?: boolean } = {}): CredentialPayload => ({ name: randomName(), type: randomName(), data: { accessToken: randomString(6, 16) }, + isManaged, }); export const uniqueId = () => uuid(); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 2afe6ec328f2f..2a789e4f00d0e 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -42,7 +42,8 @@ type EndpointGroup = | 'role' | 'dynamic-node-parameters' | 'apiKeys' - | 'evaluation'; + | 'evaluation' + | 'ai'; export interface SetupProps { endpointGroups?: EndpointGroup[]; @@ -68,6 +69,7 @@ export type CredentialPayload = { name: string; type: string; data: ICredentialDataDecryptedObject; + isManaged?: boolean; }; export type SaveCredentialFunction = ( diff --git a/packages/cli/test/integration/shared/utils/task-broker-test-server.ts b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts index 9363fc089ee49..86651eb04c656 100644 --- a/packages/cli/test/integration/shared/utils/task-broker-test-server.ts +++ b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts @@ -3,7 +3,7 @@ import request from 'supertest'; import type TestAgent from 'supertest/lib/agent'; import Container from 'typedi'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; export interface TestTaskBrokerServer { server: TaskRunnerServer; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index ef0588b8d731c..d4c343772876a 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -1,5 +1,6 @@ import cookieParser from 'cookie-parser'; import express from 'express'; +import { Logger } from 'n8n-core'; import type superagent from 'superagent'; import request from 'supertest'; import { Container } from 'typedi'; @@ -11,7 +12,6 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import type { User } from '@/databases/entities/user'; import { ControllerRegistry } from '@/decorators'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { rawBodyReader, bodyParser } from '@/middlewares'; import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; @@ -171,7 +171,7 @@ export const setupTestServer = ({ break; case 'variables': - await import('@/environments/variables/variables.controller.ee'); + await import('@/environments.ee/variables/variables.controller.ee'); break; case 'license': @@ -202,20 +202,22 @@ export const setupTestServer = ({ break; case 'ldap': - const { LdapService } = await import('@/ldap/ldap.service.ee'); - await import('@/ldap/ldap.controller.ee'); + const { LdapService } = await import('@/ldap.ee/ldap.service.ee'); + await import('@/ldap.ee/ldap.controller.ee'); testServer.license.enable('feat:ldap'); await Container.get(LdapService).init(); break; case 'saml': - const { setSamlLoginEnabled } = await import('@/sso/saml/saml-helpers'); - await import('@/sso/saml/routes/saml.controller.ee'); + const { SamlService } = await import('@/sso.ee/saml/saml.service.ee'); + await Container.get(SamlService).init(); + await import('@/sso.ee/saml/routes/saml.controller.ee'); + const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers'); await setSamlLoginEnabled(true); break; case 'sourceControl': - await import('@/environments/source-control/source-control.controller.ee'); + await import('@/environments.ee/source-control/source-control.controller.ee'); break; case 'community-packages': @@ -247,11 +249,11 @@ export const setupTestServer = ({ break; case 'externalSecrets': - await import('@/external-secrets/external-secrets.controller.ee'); + await import('@/external-secrets.ee/external-secrets.controller.ee'); break; case 'workflowHistory': - await import('@/workflows/workflow-history/workflow-history.controller.ee'); + await import('@/workflows/workflow-history.ee/workflow-history.controller.ee'); break; case 'binaryData': @@ -279,10 +281,13 @@ export const setupTestServer = ({ break; case 'evaluation': - await import('@/evaluation/metrics.controller'); - await import('@/evaluation/test-definitions.controller.ee'); - await import('@/evaluation/test-runs.controller.ee'); + await import('@/evaluation.ee/metrics.controller'); + await import('@/evaluation.ee/test-definitions.controller.ee'); + await import('@/evaluation.ee/test-runs.controller.ee'); break; + + case 'ai': + await import('@/controllers/ai.controller'); } } diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index 7dd8d00aaedd8..c331da8e9999f 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -3,7 +3,7 @@ import { Container } from 'typedi'; import type { Variables } from '@/databases/entities/variables'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { CacheService } from '@/services/cache/cache.service'; import { createOwner, createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 825da9fcbf021..eaf5d7447848d 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -5,7 +5,7 @@ import Container from 'typedi'; import config from '@/config'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { License } from '@/license'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; import { createManyWorkflowHistoryItems } from './shared/db/workflow-history'; import { createWorkflow } from './shared/db/workflows'; diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index 3730d0db6cd11..ab02af5b43a66 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -2,7 +2,7 @@ import Container from 'typedi'; import type { User } from '@/databases/entities/user'; import { License } from '@/license'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { createUser } from '../shared/db/users'; diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fb28918509e57..e7e00d63c82e8 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -12,7 +12,7 @@ import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-his import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; import type { ListQuery } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; import { mockInstance } from '../../shared/mocking'; @@ -441,6 +441,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -456,6 +457,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -833,6 +835,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -842,6 +845,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], diff --git a/packages/cli/test/shared/mocking.ts b/packages/cli/test/shared/mocking.ts index 129acb585c62a..535388c55626b 100644 --- a/packages/cli/test/shared/mocking.ts +++ b/packages/cli/test/shared/mocking.ts @@ -1,11 +1,10 @@ import { DataSource, EntityManager, type EntityMetadata } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import type { Class } from 'n8n-core'; +import type { Logger } from 'n8n-core'; import type { DeepPartial } from 'ts-essentials'; import { Container } from 'typedi'; -import type { Logger } from '@/logging/logger.service'; - export const mockInstance = ( serviceClass: Class, data: DeepPartial | undefined = undefined, diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d145ba0c63615..3789372ef8b07 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -15,7 +15,7 @@ "strict": false, "useUnknownInCatchVariables": false }, - "include": ["src/**/*.ts", "test/**/*.ts", "src/sso/saml/saml-schema-metadata-2.0.xsd"], + "include": ["src/**/*.ts", "test/**/*.ts", "src/sso.ee/saml/saml-schema-metadata-2.0.xsd"], "references": [ { "path": "../workflow/tsconfig.build.json" }, { "path": "../core/tsconfig.build.json" }, diff --git a/packages/core/package.json b/packages/core/package.json index 26b41800d9c75..401a9f81c749f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ "@sentry/node": "catalog:", "aws4": "1.11.0", "axios": "catalog:", + "callsites": "catalog:", "chardet": "2.0.0", "concat-stream": "2.0.0", "cron": "3.1.7", @@ -56,11 +57,13 @@ "nanoid": "catalog:", "oauth-1.0a": "2.2.6", "p-cancelable": "2.1.1", + "picocolors": "catalog:", "pretty-bytes": "5.6.0", "qs": "6.11.0", "ssh2": "1.15.0", "typedi": "catalog:", "uuid": "catalog:", + "winston": "3.14.2", "xml2js": "catalog:", "zod": "catalog:" } diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index e3ca8614c2fa8..173f73baca939 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -11,7 +11,6 @@ import type { } from 'n8n-workflow'; import { ApplicationError, - LoggerProxy as Logger, toCronExpression, TriggerCloseError, WorkflowActivationError, @@ -21,12 +20,14 @@ import { Service } from 'typedi'; import { ErrorReporter } from './error-reporter'; import type { IWorkflowData } from './Interfaces'; +import { Logger } from './logging/logger'; import { ScheduledTaskManager } from './ScheduledTaskManager'; import { TriggersAndPollers } from './TriggersAndPollers'; @Service() export class ActiveWorkflows { constructor( + private readonly logger: Logger, private readonly scheduledTaskManager: ScheduledTaskManager, private readonly triggersAndPollers: TriggersAndPollers, private readonly errorReporter: ErrorReporter, @@ -151,7 +152,7 @@ export class ActiveWorkflows { const cronTimes = (pollTimes.item || []).map(toCronExpression); // The trigger function to execute when the cron-time got reached const executeTrigger = async (testingTrigger = false) => { - Logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, { + this.logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, { workflowName: workflow.name, workflowId: workflow.id, }); @@ -193,7 +194,7 @@ export class ActiveWorkflows { */ async remove(workflowId: string) { if (!this.isActive(workflowId)) { - Logger.warn(`Cannot deactivate already inactive workflow ID "${workflowId}"`); + this.logger.warn(`Cannot deactivate already inactive workflow ID "${workflowId}"`); return false; } @@ -222,7 +223,7 @@ export class ActiveWorkflows { await response.closeFunction(); } catch (e) { if (e instanceof TriggerCloseError) { - Logger.error( + this.logger.error( `There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`, ); this.errorReporter.error(e, { extra: { workflowId } }); diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts index ceaf77566a0d3..82a39b07cd26d 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/Constants.ts @@ -1,6 +1,10 @@ import type { INodeProperties } from 'n8n-workflow'; import { cronNodeOptions } from 'n8n-workflow'; +const { NODE_ENV } = process.env; +export const inProduction = NODE_ENV === 'production'; +export const inDevelopment = !NODE_ENV || NODE_ENV === 'development'; + export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__'; export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index cd223da2ddf6f..fe9fee87694e4 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -15,15 +15,13 @@ import type { IVersionedNodeType, KnownNodesAndCredentials, } from 'n8n-workflow'; -import { - ApplicationError, - LoggerProxy as Logger, - applyDeclarativeNodeOptionParameters, - jsonParse, -} from 'n8n-workflow'; +import { ApplicationError, applyDeclarativeNodeOptionParameters, jsonParse } from 'n8n-workflow'; import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as path from 'path'; +import Container from 'typedi'; + +import { Logger } from '@/logging/logger'; import { loadClassInIsolation } from './ClassLoader'; import { commonCORSParameters, commonPollingParameters, CUSTOM_NODES_CATEGORY } from './Constants'; @@ -78,6 +76,8 @@ export abstract class DirectoryLoader { readonly nodesByCredential: Record = {}; + protected readonly logger = Container.get(Logger); + constructor( readonly directory: string, protected readonly excludeNodes: string[] = [], @@ -336,7 +336,7 @@ export abstract class DirectoryLoader { node.description.codex = codex; } catch { - Logger.debug(`No codex available for: ${node.description.name}`); + this.logger.debug(`No codex available for: ${node.description.name}`); if (isCustom) { node.description.codex = { @@ -454,7 +454,7 @@ export class PackageDirectoryLoader extends DirectoryLoader { this.inferSupportedNodes(); - Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { + this.logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { credentials: credentials?.length ?? 0, nodes: nodes?.length ?? 0, }); @@ -550,7 +550,7 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { ); } - Logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { + this.logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { nodes: this.types.nodes?.length ?? 0, credentials: this.types.credentials?.length ?? 0, }); @@ -559,7 +559,7 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { return; // We can load nodes and credentials lazily now } catch { - Logger.debug("Can't enable lazy-loading"); + this.logger.debug("Can't enable lazy-loading"); await super.loadAll(); } } diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/InstanceSettings.ts index f611e034b337f..ebdfb7fd63ee4 100644 --- a/packages/core/src/InstanceSettings.ts +++ b/packages/core/src/InstanceSettings.ts @@ -5,6 +5,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync import path from 'path'; import { Service } from 'typedi'; +import { Logger } from '@/logging/logger'; + import { Memoized } from './decorators'; import { InstanceSettingsConfig } from './InstanceSettingsConfig'; @@ -28,13 +30,11 @@ const inTest = process.env.NODE_ENV === 'test'; @Service() export class InstanceSettings { - private readonly userHome = this.getUserHome(); - /** The path to the n8n folder in which all n8n related data gets saved */ - readonly n8nFolder = path.join(this.userHome, '.n8n'); + readonly n8nFolder = this.config.n8nFolder; /** The path to the folder where all generated static assets are copied to */ - readonly staticCacheDir = path.join(this.userHome, '.cache/n8n/public'); + readonly staticCacheDir = path.join(this.config.userHome, '.cache/n8n/public'); /** The path to the folder containing custom nodes and credentials */ readonly customExtensionDir = path.join(this.n8nFolder, 'custom'); @@ -58,7 +58,10 @@ export class InstanceSettings { readonly instanceType: InstanceType; - constructor(private readonly config: InstanceSettingsConfig) { + constructor( + private readonly config: InstanceSettingsConfig, + private readonly logger: Logger, + ) { const command = process.argv[2]; this.instanceType = ['webhook', 'worker'].includes(command) ? (command as InstanceType) @@ -110,6 +113,10 @@ export class InstanceSettings { return !this.isMultiMain; } + get isWorker() { + return this.instanceType === 'worker'; + } + get isLeader() { return this.instanceRole === 'leader'; } @@ -154,15 +161,6 @@ export class InstanceSettings { this.save({ ...this.settings, ...newSettings }); } - /** - * The home folder path of the user. - * If none can be found it falls back to the current working directory - */ - private getUserHome() { - const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; - return process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); - } - /** * Load instance settings from the settings file. If missing, create a new * settings file with an auto-generated encryption key. @@ -198,7 +196,9 @@ export class InstanceSettings { this.save(settings); if (!inTest && !process.env.N8N_ENCRYPTION_KEY) { - console.info(`No encryption key found - Auto-generated and saved to: ${this.settingsFile}`); + this.logger.info( + `No encryption key found - Auto-generated and saved to: ${this.settingsFile}`, + ); } this.ensureSettingsFilePermissions(); @@ -260,11 +260,11 @@ export class InstanceSettings { const permissionsResult = toResult(() => { const stats = statSync(this.settingsFile); - return stats.mode & 0o777; + return stats?.mode & 0o777; }); // If we can't determine the permissions, log a warning and skip the check if (!permissionsResult.ok) { - console.warn( + this.logger.warn( `Could not ensure settings file permissions: ${permissionsResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); return; @@ -277,7 +277,7 @@ export class InstanceSettings { // If the permissions are incorrect and the flag is not set, log a warning if (!this.enforceSettingsFilePermissions.isSet) { - console.warn( + this.logger.warn( `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); // The default is false so we skip the enforcement for now @@ -285,7 +285,7 @@ export class InstanceSettings { } if (this.enforceSettingsFilePermissions.enforce) { - console.warn( + this.logger.warn( `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`, ); const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600)); @@ -293,7 +293,7 @@ export class InstanceSettings { // Some filesystems don't support permissions. In this case we log the // error and ignore it. We might want to prevent the app startup in the // future in this case. - console.warn( + this.logger.warn( `Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); } diff --git a/packages/core/src/InstanceSettingsConfig.ts b/packages/core/src/InstanceSettingsConfig.ts index 60baf8b80f9cd..dd28472a05050 100644 --- a/packages/core/src/InstanceSettingsConfig.ts +++ b/packages/core/src/InstanceSettingsConfig.ts @@ -1,4 +1,5 @@ import { Config, Env } from '@n8n/config'; +import path from 'node:path'; @Config export class InstanceSettingsConfig { @@ -9,4 +10,19 @@ export class InstanceSettingsConfig { */ @Env('N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS') enforceSettingsFilePermissions: boolean = false; + + /** + * The home folder path of the user. + * If none can be found it falls back to the current working directory + */ + readonly userHome: string; + + readonly n8nFolder: string; + + constructor() { + const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; + this.userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); + + this.n8nFolder = path.join(this.userHome, '.n8n'); + } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 20d5ae0833f04..3a0ebf22f9309 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -76,7 +76,6 @@ import type { SchedulingFunctions, } from 'n8n-workflow'; import { - LoggerProxy as Logger, NodeApiError, NodeHelpers, NodeOperationError, @@ -97,6 +96,8 @@ import { Readable } from 'stream'; import Container from 'typedi'; import url, { URL, URLSearchParams } from 'url'; +import { Logger } from '@/logging/logger'; + import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { BinaryData } from './BinaryData/types'; import { binaryToBuffer } from './BinaryData/utils'; @@ -201,7 +202,7 @@ async function generateContentLengthHeader(config: AxiosRequestConfig) { 'content-length': length, }; } catch (error) { - Logger.error('Unable to calculate form data length', { error }); + Container.get(Logger).error('Unable to calculate form data length', { error }); } } @@ -792,7 +793,7 @@ export async function proxyRequestToAxios( error.config = error.request = undefined; error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']); if (response) { - Logger.debug('Request proxied to Axios failed', { status: response.status }); + Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status }); let responseData = response.data; if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { @@ -1406,7 +1407,7 @@ export async function requestOAuth2( if (isN8nRequest) { return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { if (error.response?.status === 401) { - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); const tokenRefreshOptions: IDataObject = {}; @@ -1425,7 +1426,7 @@ export async function requestOAuth2( let newToken; - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); // if it's OAuth2 with client credentials grant type, get a new token @@ -1436,7 +1437,7 @@ export async function requestOAuth2( newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); @@ -1499,7 +1500,7 @@ export async function requestOAuth2( Authorization: '', }; } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); @@ -1512,7 +1513,7 @@ export async function requestOAuth2( } else { newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); @@ -1534,7 +1535,7 @@ export async function requestOAuth2( credentials as unknown as ICredentialDataDecryptedObject, ); - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, ); @@ -2562,6 +2563,7 @@ export function getExecuteTriggerFunctions( export function getCredentialTestFunctions(): ICredentialTestFunctions { return { + logger: Container.get(Logger), helpers: { ...getSSHTunnelFunctions(), request: async (uriOrObject: string | object, options?: object) => { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 0e4d8463dfccb..6379de7789f88 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -2,11 +2,13 @@ import { sign } from 'aws4'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import axios from 'axios'; import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, Method } from 'axios'; -import { ApplicationError, LoggerProxy as Logger } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import { createHash } from 'node:crypto'; import type { Readable } from 'stream'; import { Service } from 'typedi'; +import { Logger } from '@/logging/logger'; + import type { Bucket, ConfigSchemaCredentials, @@ -30,7 +32,7 @@ export class ObjectStoreService { private isReadOnly = false; - private logger = Logger; + constructor(private readonly logger: Logger) {} async init(host: string, bucket: Bucket, credentials: ConfigSchemaCredentials) { this.host = host; diff --git a/packages/core/src/SerializedBuffer.ts b/packages/core/src/SerializedBuffer.ts index 7a96884729bcf..d6ea874c7a61d 100644 --- a/packages/core/src/SerializedBuffer.ts +++ b/packages/core/src/SerializedBuffer.ts @@ -1,4 +1,4 @@ -import { isObjectLiteral } from './utils'; +import { isObjectLiteral } from '@/utils'; /** A nodejs Buffer gone through JSON.stringify */ export type SerializedBuffer = { diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index f340c8da675ba..ebfad1e2045b1 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -417,6 +417,17 @@ export class WorkflowExecute { return await this.additionalData.hooks.executeHookFunctions(hookName, parameters); } + /** + * Merges temporary execution metadata into the final runData structure. + * During workflow execution, metadata is collected in a temporary location + * (executionData.metadata). This method moves that metadata to its final + * location in the resultData.runData for each node. + * + * @remarks + * - Metadata from multiple runs is preserved using run indices + * - Existing metadata in runData is preserved and merged with new metadata + * - If no metadata exists, the operation is a no-op + */ moveNodeMetadata(): void { const metadata = get(this.runExecutionData, 'executionData.metadata'); @@ -437,14 +448,27 @@ export class WorkflowExecute { } /** - * Checks the incoming connection does not receive any data + * Checks if all incoming connections to a node are empty (have no data). + * This is used to determine if a node should be executed or skipped. + * + * @param runData - The execution data from all nodes in the workflow + * @param inputConnections - Array of connections to check + * @param runIndex - Index of the current execution run (nodes can execute multiple times) + * + * @returns `true` if all connections are empty (no data), `false` if any connection has data + * + * @remarks + * A connection is considered empty when: + * - The source node doesn't exist in runData + * - The source node's data is undefined + * - The source node's output array is empty + * - The specified output index contains no items */ incomingConnectionIsEmpty( runData: IRunData, inputConnections: IConnection[], runIndex: number, ): boolean { - // for (const inputConnection of workflow.connectionsByDestinationNode[nodeToAdd].main[0]) { for (const inputConnection of inputConnections) { const nodeIncomingData = get(runData, [ inputConnection.node, @@ -460,24 +484,29 @@ export class WorkflowExecute { return true; } + /** + * Prepares the waiting execution data structure for a node that needs to wait for data before it can execute. + * This function initializes arrays to store data and metadata for each connection of the node. + * + * @param nodeName - The name of the node to prepare waiting execution for + * @param numberOfConnections - Number of input connections the node has + * @param runIndex - The index of the current run (for nodes that may run multiple times) + */ prepareWaitingToExecution(nodeName: string, numberOfConnections: number, runIndex: number) { - if (!this.runExecutionData.executionData!.waitingExecutionSource) { - this.runExecutionData.executionData!.waitingExecutionSource = {}; - } + const executionData = this.runExecutionData.executionData!; - this.runExecutionData.executionData!.waitingExecution[nodeName][runIndex] = { - main: [], - }; - this.runExecutionData.executionData!.waitingExecutionSource[nodeName][runIndex] = { - main: [], - }; + executionData.waitingExecution ??= {}; + executionData.waitingExecutionSource ??= {}; - for (let i = 0; i < numberOfConnections; i++) { - this.runExecutionData.executionData!.waitingExecution[nodeName][runIndex].main.push(null); + const nodeWaiting = (executionData.waitingExecution[nodeName] ??= []); + const nodeWaitingSource = (executionData.waitingExecutionSource[nodeName] ??= []); - this.runExecutionData.executionData!.waitingExecutionSource[nodeName][runIndex].main.push( - null, - ); + nodeWaiting[runIndex] = { main: [] }; + nodeWaitingSource[runIndex] = { main: [] }; + + for (let i = 0; i < numberOfConnections; i++) { + nodeWaiting[runIndex].main.push(null); + nodeWaitingSource[runIndex].main.push(null); } } @@ -1489,119 +1518,7 @@ export class WorkflowExecute { } if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') { - // If errorOutput is activated check all the output items for error data. - // If any is found, route them to the last output as that will be the - // error output. - - const nodeType = workflow.nodeTypes.getByNameAndVersion( - executionData.node.type, - executionData.node.typeVersion, - ); - const outputs = NodeHelpers.getNodeOutputs( - workflow, - executionData.node, - nodeType.description, - ); - const outputTypes = NodeHelpers.getConnectionTypes(outputs); - const mainOutputTypes = outputTypes.filter( - (output) => output === NodeConnectionType.Main, - ); - - const errorItems: INodeExecutionData[] = []; - const closeFunctions: CloseFunction[] = []; - // Create a WorkflowDataProxy instance that we can get the data of the - // item which did error - const executeFunctions = new ExecuteContext( - workflow, - executionData.node, - this.additionalData, - this.mode, - this.runExecutionData, - runIndex, - [], - executionData.data, - executionData, - closeFunctions, - this.abortController.signal, - ); - - const dataProxy = executeFunctions.getWorkflowDataProxy(0); - - // Loop over all outputs except the error output as it would not contain data by default - for ( - let outputIndex = 0; - outputIndex < mainOutputTypes.length - 1; - outputIndex++ - ) { - const successItems: INodeExecutionData[] = []; - const items = nodeSuccessData[outputIndex]?.length - ? nodeSuccessData[outputIndex] - : []; - - while (items.length) { - const item = items.shift(); - if (item === undefined) { - continue; - } - - let errorData: GenericValue | undefined; - if (item.error) { - errorData = item.error; - item.error = undefined; - } else if (item.json.error && Object.keys(item.json).length === 1) { - errorData = item.json.error; - } else if ( - item.json.error && - item.json.message && - Object.keys(item.json).length === 2 - ) { - errorData = item.json.error; - } - - if (errorData) { - const pairedItemData = - item.pairedItem && typeof item.pairedItem === 'object' - ? Array.isArray(item.pairedItem) - ? item.pairedItem[0] - : item.pairedItem - : undefined; - - if (executionData!.source === null || pairedItemData === undefined) { - // Source data is missing for some reason so we can not figure out the item - errorItems.push(item); - } else { - const pairedItemInputIndex = pairedItemData.input || 0; - - const sourceData = - executionData!.source[NodeConnectionType.Main][pairedItemInputIndex]; - - const constPairedItem = dataProxy.$getPairedItem( - sourceData!.previousNode, - sourceData, - pairedItemData, - ); - - if (constPairedItem === null) { - errorItems.push(item); - } else { - errorItems.push({ - ...item, - json: { - ...constPairedItem.json, - ...item.json, - }, - }); - } - } - } else { - successItems.push(item); - } - } - - nodeSuccessData[outputIndex] = successItems; - } - - nodeSuccessData[mainOutputTypes.length - 1] = errorItems; + this.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, runIndex); } if (runNodeData.closeFunction) { @@ -1616,53 +1533,7 @@ export class WorkflowExecute { workflowId: workflow.id, }); - if (nodeSuccessData?.length) { - // Check if the output data contains pairedItem data and if not try - // to automatically fix it - - const isSingleInputAndOutput = - executionData.data.main.length === 1 && executionData.data.main[0]?.length === 1; - - const isSameNumberOfItems = - nodeSuccessData.length === 1 && - executionData.data.main.length === 1 && - executionData.data.main[0]?.length === nodeSuccessData[0].length; - - checkOutputData: for (const outputData of nodeSuccessData) { - if (outputData === null) { - continue; - } - for (const [index, item] of outputData.entries()) { - if (item.pairedItem === undefined) { - // The pairedItem data is missing, so check if it can get automatically fixed - if (isSingleInputAndOutput) { - // The node has one input and one incoming item, so we know - // that all items must originate from that single - item.pairedItem = { - item: 0, - }; - } else if (isSameNumberOfItems) { - // The number of oncoming and outcoming items is identical so we can - // make the reasonable assumption that each of the input items - // is the origin of the corresponding output items - item.pairedItem = { - item: index, - }; - } else { - // In all other cases autofixing is not possible - break checkOutputData; - } - } - } - } - } - - if (nodeSuccessData === undefined) { - // Node did not get executed - nodeSuccessData = null; - } else { - this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; - } + nodeSuccessData = this.assignPairedItems(nodeSuccessData, executionData); if (nodeSuccessData === null || nodeSuccessData[0][0] === undefined) { if (executionData.node.alwaysOutputData === true) { @@ -2175,6 +2046,27 @@ export class WorkflowExecute { }); } + /** + * Processes the final state of a workflow execution and prepares the execution result. + * This method handles different completion scenarios: success, waiting, error, and canceled states. + * It also manages cleanup tasks like static data updates and trigger deactivation. + * + * @param startedAt - The timestamp when the workflow execution started + * @param workflow - The workflow being executed + * @param executionError - Optional error that occurred during execution + * @param closeFunction - Optional promise that handles cleanup of triggers/webhooks + * + * @returns A promise that resolves to the complete workflow execution data (IRun) + * + * @remarks + * The function performs these tasks in order: + * 1. Generates full execution data + * 2. Sets appropriate status based on execution outcome + * 3. Handles any static data changes + * 4. Moves node metadata to its final location + * 5. Executes the 'workflowExecuteAfter' hook + * 6. Performs cleanup via closeFunction if provided + */ async processSuccessExecution( startedAt: Date, workflow: Workflow, @@ -2240,15 +2132,187 @@ export class WorkflowExecute { } getFullRunData(startedAt: Date): IRun { - const fullRunData: IRun = { + return { data: this.runExecutionData, mode: this.mode, startedAt, stoppedAt: new Date(), status: this.status, }; + } - return fullRunData; + handleNodeErrorOutput( + workflow: Workflow, + executionData: IExecuteData, + nodeSuccessData: INodeExecutionData[][], + runIndex: number, + ): void { + const nodeType = workflow.nodeTypes.getByNameAndVersion( + executionData.node.type, + executionData.node.typeVersion, + ); + const outputs = NodeHelpers.getNodeOutputs(workflow, executionData.node, nodeType.description); + const outputTypes = NodeHelpers.getConnectionTypes(outputs); + const mainOutputTypes = outputTypes.filter((output) => output === NodeConnectionType.Main); + + const errorItems: INodeExecutionData[] = []; + const closeFunctions: CloseFunction[] = []; + // Create a WorkflowDataProxy instance that we can get the data of the + // item which did error + const executeFunctions = new ExecuteContext( + workflow, + executionData.node, + this.additionalData, + this.mode, + this.runExecutionData, + runIndex, + [], + executionData.data, + executionData, + closeFunctions, + this.abortController.signal, + ); + + const dataProxy = executeFunctions.getWorkflowDataProxy(0); + + // Loop over all outputs except the error output as it would not contain data by default + for (let outputIndex = 0; outputIndex < mainOutputTypes.length - 1; outputIndex++) { + const successItems: INodeExecutionData[] = []; + const items = nodeSuccessData[outputIndex]?.length ? nodeSuccessData[outputIndex] : []; + + while (items.length) { + const item = items.shift(); + if (item === undefined) { + continue; + } + + let errorData: GenericValue | undefined; + if (item.error) { + errorData = item.error; + item.error = undefined; + } else if (item.json.error && Object.keys(item.json).length === 1) { + errorData = item.json.error; + } else if (item.json.error && item.json.message && Object.keys(item.json).length === 2) { + errorData = item.json.error; + } + + if (errorData) { + const pairedItemData = + item.pairedItem && typeof item.pairedItem === 'object' + ? Array.isArray(item.pairedItem) + ? item.pairedItem[0] + : item.pairedItem + : undefined; + + if (executionData.source === null || pairedItemData === undefined) { + // Source data is missing for some reason so we can not figure out the item + errorItems.push(item); + } else { + const pairedItemInputIndex = pairedItemData.input || 0; + + const sourceData = executionData.source[NodeConnectionType.Main][pairedItemInputIndex]; + + const constPairedItem = dataProxy.$getPairedItem( + sourceData!.previousNode, + sourceData, + pairedItemData, + ); + + if (constPairedItem === null) { + errorItems.push(item); + } else { + errorItems.push({ + ...item, + json: { + ...constPairedItem.json, + ...item.json, + }, + }); + } + } + } else { + successItems.push(item); + } + } + + nodeSuccessData[outputIndex] = successItems; + } + + nodeSuccessData[mainOutputTypes.length - 1] = errorItems; + } + + /** + * Assigns pairedItem information to node output items by matching them with input items. + * PairedItem data is used to track which output items were derived from which input items. + * + * @param nodeSuccessData - The output data from a node execution + * @param executionData - The execution data containing input information + * + * @returns The node output data with pairedItem information assigned where possible + * + * @remarks + * Auto-assignment of pairedItem happens in two scenarios: + * 1. Single input/output: When node has exactly one input item and produces output(s), + * all outputs are marked as derived from that single input (item: 0) + * 2. Matching items count: When number of input and output items match exactly, + * each output item is paired with the input item at the same index + * + * In all other cases, if pairedItem is missing, it remains undefined as automatic + * assignment cannot be done reliably. + */ + assignPairedItems( + nodeSuccessData: INodeExecutionData[][] | null | undefined, + executionData: IExecuteData, + ) { + if (nodeSuccessData?.length) { + // Check if the output data contains pairedItem data and if not try + // to automatically fix it + + const isSingleInputAndOutput = + executionData.data.main.length === 1 && executionData.data.main[0]?.length === 1; + + const isSameNumberOfItems = + nodeSuccessData.length === 1 && + executionData.data.main.length === 1 && + executionData.data.main[0]?.length === nodeSuccessData[0].length; + + checkOutputData: for (const outputData of nodeSuccessData) { + if (outputData === null) { + continue; + } + for (const [index, item] of outputData.entries()) { + if (item.pairedItem === undefined) { + // The pairedItem data is missing, so check if it can get automatically fixed + if (isSingleInputAndOutput) { + // The node has one input and one incoming item, so we know + // that all items must originate from that single + item.pairedItem = { + item: 0, + }; + } else if (isSameNumberOfItems) { + // The number of oncoming and outcoming items is identical so we can + // make the reasonable assumption that each of the input items + // is the origin of the corresponding output items + item.pairedItem = { + item: index, + }; + } else { + // In all other cases autofixing is not possible + break checkOutputData; + } + } + } + } + } + + if (nodeSuccessData === undefined) { + // Node did not get executed + nodeSuccessData = null; + } else { + this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + } + + return nodeSuccessData; } private get isCancelled() { diff --git a/packages/core/src/__tests__/ActiveWorkflows.test.ts b/packages/core/src/__tests__/ActiveWorkflows.test.ts index 85487a0cece09..410b4779bae94 100644 --- a/packages/core/src/__tests__/ActiveWorkflows.test.ts +++ b/packages/core/src/__tests__/ActiveWorkflows.test.ts @@ -42,7 +42,12 @@ describe('ActiveWorkflows', () => { beforeEach(() => { jest.clearAllMocks(); - activeWorkflows = new ActiveWorkflows(scheduledTaskManager, triggersAndPollers, errorReporter); + activeWorkflows = new ActiveWorkflows( + mock(), + scheduledTaskManager, + triggersAndPollers, + errorReporter, + ); }); type PollTimes = { item: TriggerTime[] }; diff --git a/packages/core/src/error-reporter.ts b/packages/core/src/error-reporter.ts index b6fc936daaa7a..910b309270661 100644 --- a/packages/core/src/error-reporter.ts +++ b/packages/core/src/error-reporter.ts @@ -2,11 +2,12 @@ import type { NodeOptions } from '@sentry/node'; import { close } from '@sentry/node'; import type { ErrorEvent, EventHint } from '@sentry/types'; import { AxiosError } from 'axios'; -import { ApplicationError, LoggerProxy, type ReportingOptions } from 'n8n-workflow'; +import { ApplicationError, ExecutionCancelledError, type ReportingOptions } from 'n8n-workflow'; import { createHash } from 'node:crypto'; import { Service } from 'typedi'; import type { InstanceType } from './InstanceSettings'; +import { Logger } from './logging/logger'; @Service() export class ErrorReporter { @@ -15,7 +16,7 @@ export class ErrorReporter { private report: (error: Error | string, options?: ReportingOptions) => void; - constructor() { + constructor(private readonly logger: Logger) { // eslint-disable-next-line @typescript-eslint/unbound-method this.report = this.defaultReport; } @@ -28,9 +29,12 @@ export class ErrorReporter { const context = executionId ? ` (execution ${executionId})` : ''; do { - const msg = [e.message + context, e.stack ? `\n${e.stack}\n` : ''].join(''); + const msg = [ + e.message + context, + e instanceof ApplicationError && e.level === 'error' && e.stack ? `\n${e.stack}\n` : '', + ].join(''); const meta = e instanceof ApplicationError ? e.extra : undefined; - LoggerProxy.error(msg, meta); + this.logger.error(msg, meta); e = e.cause as Error; } while (e); } @@ -142,6 +146,7 @@ export class ErrorReporter { } error(e: unknown, options?: ReportingOptions) { + if (e instanceof ExecutionCancelledError) return; const toReport = this.wrap(e); if (toReport) this.report(toReport, options); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bec9767ffa923..7abbd9ad9aa7f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from './Credentials'; export * from './DirectoryLoader'; export * from './Interfaces'; export { InstanceSettings, InstanceType } from './InstanceSettings'; +export { Logger } from './logging/logger'; export * from './NodeExecuteFunctions'; export * from './RoutingNode'; export * from './WorkflowExecute'; diff --git a/packages/cli/src/logging/__tests__/logger.service.test.ts b/packages/core/src/logging/__tests__/logger.test.ts similarity index 93% rename from packages/cli/src/logging/__tests__/logger.service.test.ts rename to packages/core/src/logging/__tests__/logger.test.ts index 2ffbf2120eb34..d34eaf250a099 100644 --- a/packages/cli/src/logging/__tests__/logger.service.test.ts +++ b/packages/core/src/logging/__tests__/logger.test.ts @@ -5,10 +5,11 @@ jest.mock('n8n-workflow', () => ({ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; -import type { InstanceSettings } from 'n8n-core'; import { LoggerProxy } from 'n8n-workflow'; -import { Logger } from '@/logging/logger.service'; +import type { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; + +import { Logger } from '../logger'; describe('Logger', () => { beforeEach(() => { @@ -25,13 +26,13 @@ describe('Logger', () => { }); test('if root, should initialize `LoggerProxy` with instance', () => { - const logger = new Logger(globalConfig, mock(), { isRoot: true }); + const logger = new Logger(globalConfig, mock(), { isRoot: true }); expect(LoggerProxy.init).toHaveBeenCalledWith(logger); }); test('if scoped, should not initialize `LoggerProxy`', () => { - new Logger(globalConfig, mock(), { isRoot: false }); + new Logger(globalConfig, mock(), { isRoot: false }); expect(LoggerProxy.init).not.toHaveBeenCalled(); }); @@ -47,7 +48,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const { transports } = logger.getInternalLogger(); @@ -72,7 +73,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock({ n8nFolder: '/tmp' })); + const logger = new Logger(globalConfig, mock({ n8nFolder: '/tmp' })); const { transports } = logger.getInternalLogger(); @@ -94,7 +95,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -113,7 +114,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -132,7 +133,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -151,7 +152,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -170,7 +171,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); diff --git a/packages/cli/src/logging/logger.service.ts b/packages/core/src/logging/logger.ts similarity index 77% rename from packages/cli/src/logging/logger.service.ts rename to packages/core/src/logging/logger.ts index 46441e5a339ca..654d16f7b051e 100644 --- a/packages/cli/src/logging/logger.service.ts +++ b/packages/core/src/logging/logger.ts @@ -2,20 +2,26 @@ import type { LogScope } from '@n8n/config'; import { GlobalConfig } from '@n8n/config'; import callsites from 'callsites'; import type { TransformableInfo } from 'logform'; -import { InstanceSettings, isObjectLiteral } from 'n8n-core'; import { LoggerProxy, LOG_LEVELS } from 'n8n-workflow'; +import type { + Logger as LoggerType, + LogLocationMetadata, + LogLevel, + LogMetadata, +} from 'n8n-workflow'; import path, { basename } from 'node:path'; import pc from 'picocolors'; import { Service } from 'typedi'; import winston from 'winston'; -import { inDevelopment, inProduction } from '@/constants'; +import { inDevelopment, inProduction } from '@/Constants'; +import { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; +import { isObjectLiteral } from '@/utils'; -import { noOp } from './constants'; -import type { LogLocationMetadata, LogLevel, LogMetadata } from './types'; +const noOp = () => {}; @Service() -export class Logger { +export class Logger implements LoggerType { private internalLogger: winston.Logger; private readonly level: LogLevel; @@ -26,9 +32,12 @@ export class Logger { return this.scopes.size > 0; } + /** https://no-color.org/ */ + private readonly noColor = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== ''; + constructor( private readonly globalConfig: GlobalConfig, - private readonly instanceSettings: InstanceSettings, + private readonly instanceSettingsConfig: InstanceSettingsConfig, { isRoot }: { isRoot?: boolean } = { isRoot: true }, ) { this.level = this.globalConfig.logging.level; @@ -49,6 +58,8 @@ export class Logger { if (outputs.includes('file')) this.setFileTransport(); this.scopes = new Set(scopes); + } else { + this.scopes = new Set(); } if (isRoot) LoggerProxy.init(this); @@ -61,7 +72,9 @@ export class Logger { /** Create a logger that injects the given scopes into its log metadata. */ scoped(scopes: LogScope | LogScope[]) { scopes = Array.isArray(scopes) ? scopes : [scopes]; - const scopedLogger = new Logger(this.globalConfig, this.instanceSettings, { isRoot: false }); + const scopedLogger = new Logger(this.globalConfig, this.instanceSettingsConfig, { + isRoot: false, + }); const childLogger = this.internalLogger.child({ scopes }); scopedLogger.setInternalLogger(childLogger); @@ -107,10 +120,10 @@ export class Logger { } private scopeFilter() { - return winston.format((info: TransformableInfo & { metadata: LogMetadata }) => { + return winston.format((info: TransformableInfo) => { if (!this.isScopingEnabled) return info; - const { scopes } = info.metadata; + const { scopes } = (info as unknown as { metadata: LogMetadata }).metadata; const shouldIncludeScope = scopes && scopes?.length > 0 && scopes.some((s) => this.scopes.has(s)); @@ -119,18 +132,22 @@ export class Logger { })(); } + private color() { + return this.noColor ? winston.format.uncolorize() : winston.format.colorize({ all: true }); + } + private debugDevConsoleFormat() { return winston.format.combine( winston.format.metadata(), winston.format.timestamp({ format: () => this.devTsFormat() }), - winston.format.colorize({ all: true }), + this.color(), this.scopeFilter(), - winston.format.printf(({ level: _level, message, timestamp, metadata: _metadata }) => { - const SEPARATOR = ' '.repeat(3); - const LOG_LEVEL_COLUMN_WIDTH = 15; // 5 columns + ANSI color codes - const level = _level.toLowerCase().padEnd(LOG_LEVEL_COLUMN_WIDTH, ' '); - const metadata = this.toPrintable(_metadata); - return [timestamp, level, message + ' ' + pc.dim(metadata)].join(SEPARATOR); + winston.format.printf(({ level: rawLevel, message, timestamp, metadata: rawMetadata }) => { + const separator = ' '.repeat(3); + const logLevelColumnWidth = this.noColor ? 5 : 15; // when colorizing, account for ANSI color codes + const level = rawLevel.toLowerCase().padEnd(logLevelColumnWidth, ' '); + const metadata = this.toPrintable(rawMetadata); + return [timestamp, level, message + ' ' + pc.dim(metadata)].join(separator); }), ); } @@ -139,10 +156,11 @@ export class Logger { return winston.format.combine( winston.format.metadata(), winston.format.timestamp(), + this.color(), this.scopeFilter(), - winston.format.printf(({ level, message, timestamp, metadata }) => { - const _metadata = this.toPrintable(metadata); - return `${timestamp} | ${level.padEnd(5)} | ${message}${_metadata ? ' ' + _metadata : ''}`; + winston.format.printf(({ level, message, timestamp, metadata: rawMetadata }) => { + const metadata = this.toPrintable(rawMetadata); + return `${timestamp} | ${level.padEnd(5)} | ${message}${metadata ? ' ' + metadata : ''}`; }), ); } @@ -179,7 +197,7 @@ export class Logger { ); const filename = path.join( - this.instanceSettings.n8nFolder, + this.instanceSettingsConfig.n8nFolder, this.globalConfig.logging.file.location, ); diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/node-execution-context/execute-context.ts index f3e32608f3395..089c3f500a7d3 100644 --- a/packages/core/src/node-execution-context/execute-context.ts +++ b/packages/core/src/node-execution-context/execute-context.ts @@ -131,7 +131,7 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti settings: unknown, itemIndex: number, ): Promise> { - return await this.additionalData.startAgentJob( + return await this.additionalData.startRunnerTask( this.additionalData, jobType, settings, diff --git a/packages/core/src/node-execution-context/node-execution-context.ts b/packages/core/src/node-execution-context/node-execution-context.ts index 8477fe7856992..d303b10ba1b69 100644 --- a/packages/core/src/node-execution-context/node-execution-context.ts +++ b/packages/core/src/node-execution-context/node-execution-context.ts @@ -23,7 +23,6 @@ import { ApplicationError, deepCopy, ExpressionError, - LoggerProxy, NodeHelpers, NodeOperationError, } from 'n8n-workflow'; @@ -33,6 +32,7 @@ import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants import { Memoized } from '@/decorators'; import { extractValue } from '@/ExtractValue'; import { InstanceSettings } from '@/InstanceSettings'; +import { Logger } from '@/logging/logger'; import { cleanupParameterData } from './utils/cleanupParameterData'; import { ensureType } from './utils/ensureType'; @@ -53,8 +53,9 @@ export abstract class NodeExecutionContext implements Omit { const userFolder = '/test'; @@ -11,12 +14,16 @@ describe('InstanceSettings', () => { const settingsFile = `${userFolder}/.n8n/config`; const mockFs = mock(fs); + const logger = mockInstance(Logger); const createInstanceSettings = (opts?: Partial) => - new InstanceSettings({ - ...new InstanceSettingsConfig(), - ...opts, - }); + new InstanceSettings( + { + ...new InstanceSettingsConfig(), + ...opts, + }, + logger, + ); beforeEach(() => { jest.resetAllMocks(); @@ -203,7 +210,7 @@ describe('InstanceSettings', () => { mockFs.readFileSync .calledWith(settingsFile) .mockReturnValue(JSON.stringify({ encryptionKey: 'test_key' })); - settings = new InstanceSettings(mock()); + settings = createInstanceSettings(); }); it('should return true if /.dockerenv exists', () => { diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index 77936c20f0bb5..9899ad17fc3cc 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { mock } from 'jest-mock-extended'; import { Readable } from 'stream'; import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; @@ -25,7 +26,7 @@ const toDeletionXml = (filename: string) => ` let objectStoreService: ObjectStoreService; beforeEach(async () => { - objectStoreService = new ObjectStoreService(); + objectStoreService = new ObjectStoreService(mock()); mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection await objectStoreService.init(mockHost, mockBucket, mockCredentials); jest.restoreAllMocks(); diff --git a/packages/core/test/SerializedBuffer.test.ts b/packages/core/test/SerializedBuffer.test.ts index 42437296299f0..95d721340131c 100644 --- a/packages/core/test/SerializedBuffer.test.ts +++ b/packages/core/test/SerializedBuffer.test.ts @@ -7,7 +7,7 @@ const validSerializedBuffer: SerializedBuffer = { data: [65, 66, 67], // Corresponds to 'ABC' in ASCII }; -describe('serializedBufferToBuffer', () => { +describe('toBuffer', () => { it('should convert a SerializedBuffer to a Buffer', () => { const buffer = toBuffer(validSerializedBuffer); expect(buffer).toBeInstanceOf(Buffer); @@ -43,7 +43,7 @@ describe('isSerializedBuffer', () => { }); }); -describe('Integration: serializedBufferToBuffer and isSerializedBuffer', () => { +describe('Integration: toBuffer and isSerializedBuffer', () => { it('should correctly validate and convert a SerializedBuffer', () => { if (isSerializedBuffer(validSerializedBuffer)) { const buffer = toBuffer(validSerializedBuffer); diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index 6a826a8118688..6ab3afdaeb4f7 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -12,8 +12,11 @@ import { mock } from 'jest-mock-extended'; import { pick } from 'lodash'; import type { + ExecutionBaseError, + IConnection, IExecuteData, INode, + INodeExecutionData, INodeType, INodeTypes, IPinData, @@ -23,10 +26,12 @@ import type { ITriggerResponse, IWorkflowExecuteAdditionalData, WorkflowTestData, + RelatedExecution, } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise, + NodeConnectionType, NodeExecutionOutput, NodeHelpers, Workflow, @@ -604,4 +609,752 @@ describe('WorkflowExecute', () => { expect(triggerResponse.closeFunction).toHaveBeenCalled(); }); }); + + describe('handleNodeErrorOutput', () => { + const testNode: INode = { + id: '1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + const nodeType = mock({ + description: { + name: 'test', + displayName: 'test', + defaultVersion: 1, + properties: [], + inputs: [{ type: NodeConnectionType.Main }], + outputs: [ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.Main, category: 'error' }, + ], + }, + }); + + const nodeTypes = mock(); + + const workflow = new Workflow({ + id: 'test', + nodes: [testNode], + connections: {}, + active: false, + nodeTypes, + }); + + const executionData = { + node: workflow.nodes.Node1, + data: { + main: [ + [ + { + json: { data: 'test' }, + pairedItem: { item: 0, input: 0 }, + }, + ], + ], + }, + source: { + [NodeConnectionType.Main]: [ + { + previousNode: 'previousNode', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + ], + }, + }; + + const runExecutionData: IRunExecutionData = { + resultData: { + runData: { + previousNode: [ + { + data: { + main: [[{ json: { someData: 'test' } }]], + }, + source: [], + startTime: 0, + executionTime: 0, + }, + ], + }, + }, + }; + + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + jest.clearAllMocks(); + + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should handle undefined error data input correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [undefined as unknown as INodeExecutionData], + ]; + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + expect(nodeSuccessData[0]).toEqual([undefined]); + expect(nodeSuccessData[1]).toEqual([]); + }); + + test('should handle empty input', () => { + const nodeSuccessData: INodeExecutionData[][] = [[]]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toHaveLength(0); + expect(nodeSuccessData[1]).toHaveLength(0); + }); + + test('should route error items to last output', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error', additionalData: 'preserved' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { regularData: 'success' }, + pairedItem: { item: 1, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([ + { + json: { additionalData: 'preserved', error: 'Test error' }, + pairedItem: { item: 0, input: 0 }, + }, + { json: { regularData: 'success' }, pairedItem: { item: 1, input: 0 } }, + ]); + expect(nodeSuccessData[1]).toEqual([]); + }); + + test('should handle error in json with message property', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { + error: 'Error occurred', + message: 'Error details', + }, + pairedItem: { item: 0, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { + error: 'Error occurred', + message: 'Error details', + someData: 'test', + }, + pairedItem: { item: 0, input: 0 }, + }, + ]); + }); + + test('should preserve pairedItem data when routing errors', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { someData: 'test', error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ]); + }); + + test('should route multiple error items correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Error 1', data: 'preserved1' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { error: 'Error 2', data: 'preserved2' }, + pairedItem: { item: 1, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[1]).toEqual([]); + expect(nodeSuccessData[0]).toEqual([ + { + json: { error: 'Error 1', data: 'preserved1' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { error: 'Error 2', data: 'preserved2' }, + pairedItem: { item: 1, input: 0 }, + }, + ]); + }); + + test('should handle complex pairedItem data correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { someData: 'test', error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ]); + }); + }); + + describe('prepareWaitingToExecution', () => { + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { + runData: {}, + pinData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should initialize waitingExecutionSource if undefined', () => { + runExecutionData.executionData!.waitingExecutionSource = null; + const nodeName = 'testNode'; + const numberOfConnections = 2; + const runIndex = 0; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + expect(runExecutionData.executionData?.waitingExecutionSource).toBeDefined(); + }); + + test('should create arrays of correct length with null values', () => { + const nodeName = 'testNode'; + const numberOfConnections = 3; + const runIndex = 0; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + const nodeWaiting = runExecutionData.executionData!.waitingExecution[nodeName]; + const nodeWaitingSource = runExecutionData.executionData!.waitingExecutionSource![nodeName]; + + expect(nodeWaiting[runIndex].main).toHaveLength(3); + expect(nodeWaiting[runIndex].main).toEqual([null, null, null]); + expect(nodeWaitingSource[runIndex].main).toHaveLength(3); + expect(nodeWaitingSource[runIndex].main).toEqual([null, null, null]); + }); + + test('should work with zero connections', () => { + const nodeName = 'testNode'; + const numberOfConnections = 0; + const runIndex = 0; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + expect( + runExecutionData.executionData!.waitingExecution[nodeName][runIndex].main, + ).toHaveLength(0); + expect( + runExecutionData.executionData!.waitingExecutionSource![nodeName][runIndex].main, + ).toHaveLength(0); + }); + + test('should handle multiple run indices', () => { + const nodeName = 'testNode'; + const numberOfConnections = 2; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, 0); + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, 1); + + const nodeWaiting = runExecutionData.executionData!.waitingExecution[nodeName]; + const nodeWaitingSource = runExecutionData.executionData!.waitingExecutionSource![nodeName]; + + expect(nodeWaiting[0].main).toHaveLength(2); + expect(nodeWaiting[1].main).toHaveLength(2); + expect(nodeWaitingSource[0].main).toHaveLength(2); + expect(nodeWaitingSource[1].main).toHaveLength(2); + }); + }); + + describe('incomingConnectionIsEmpty', () => { + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + workflowExecute = new WorkflowExecute(mock(), 'manual'); + }); + + test('should return true when there are no input connections', () => { + const result = workflowExecute.incomingConnectionIsEmpty({}, [], 0); + expect(result).toBe(true); + }); + + test('should return true when all input connections have no data', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { main: [[], []] }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + + test('should return true when input connection node does not exist in runData', () => { + const runData: IRunData = {}; + const inputConnections: IConnection[] = [ + { node: 'nonexistentNode', type: NodeConnectionType.Main, index: 0 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + + test('should return false when any input connection has data', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { + main: [[{ json: { data: 'test' } }], []], + }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(false); + }); + + test('should check correct run index', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { + main: [[]], + }, + startTime: 0, + executionTime: 0, + }, + { + source: [], + data: { + main: [[{ json: { data: 'test' } }]], + }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + ]; + + expect(workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0)).toBe(true); + expect(workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 1)).toBe(false); + }); + + test('should handle undefined data in runData correctly', () => { + const runData: IRunData = { + node1: [ + { + source: [], + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + }); + + describe('moveNodeMetadata', () => { + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + const parentExecution = mock(); + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { + runData: {}, + pinData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should do nothing when there is no metadata', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toBeUndefined(); + }); + + test('should merge metadata into runData for single node', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toEqual({ parentExecution }); + }); + + test('should merge metadata into runData for multiple nodes', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + node2: [{ startTime: 0, executionTime: 0, source: [] }], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + node2: [{ subExecutionsCount: 4 }], + }; + + workflowExecute.moveNodeMetadata(); + + const { runData } = runExecutionData.resultData; + expect(runData.node1[0].metadata).toEqual({ parentExecution }); + expect(runData.node2[0].metadata).toEqual({ subExecutionsCount: 4 }); + }); + + test('should preserve existing metadata when merging', () => { + runExecutionData.resultData.runData = { + node1: [ + { + startTime: 0, + executionTime: 0, + source: [], + metadata: { subExecutionsCount: 4 }, + }, + ], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toEqual({ + parentExecution, + subExecutionsCount: 4, + }); + }); + + test('should handle multiple run indices', () => { + runExecutionData.resultData.runData = { + node1: [ + { startTime: 0, executionTime: 0, source: [] }, + { startTime: 0, executionTime: 0, source: [] }, + ], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }, { subExecutionsCount: 4 }], + }; + + workflowExecute.moveNodeMetadata(); + + const { runData } = runExecutionData.resultData; + expect(runData.node1[0].metadata).toEqual({ parentExecution }); + expect(runData.node1[1].metadata).toEqual({ subExecutionsCount: 4 }); + }); + }); + + describe('getFullRunData', () => { + afterAll(() => { + jest.useRealTimers(); + }); + + test('should return complete IRun object with all properties correctly set', () => { + const runExecutionData = mock(); + + const workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + + const startedAt = new Date('2023-01-01T00:00:00.000Z'); + jest.useFakeTimers().setSystemTime(startedAt); + + const result1 = workflowExecute.getFullRunData(startedAt); + + expect(result1).toEqual({ + data: runExecutionData, + mode: 'manual', + startedAt, + stoppedAt: startedAt, + status: 'new', + }); + + const stoppedAt = new Date('2023-01-01T00:00:10.000Z'); + jest.setSystemTime(stoppedAt); + // @ts-expect-error read-only property + workflowExecute.status = 'running'; + + const result2 = workflowExecute.getFullRunData(startedAt); + + expect(result2).toEqual({ + data: runExecutionData, + mode: 'manual', + startedAt, + stoppedAt, + status: 'running', + }); + }); + }); + + describe('processSuccessExecution', () => { + const startedAt: Date = new Date('2023-01-01T00:00:00.000Z'); + const workflow = new Workflow({ + id: 'test', + nodes: [], + connections: {}, + active: false, + nodeTypes: mock(), + }); + + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: null, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + + jest.spyOn(workflowExecute, 'executeHook').mockResolvedValue(undefined); + jest.spyOn(workflowExecute, 'moveNodeMetadata').mockImplementation(); + }); + + test('should handle different workflow completion scenarios', async () => { + // Test successful execution + const successResult = await workflowExecute.processSuccessExecution(startedAt, workflow); + expect(successResult.status).toBe('success'); + expect(successResult.finished).toBe(true); + + // Test execution with wait + runExecutionData.waitTill = new Date('2024-01-01'); + const waitResult = await workflowExecute.processSuccessExecution(startedAt, workflow); + expect(waitResult.status).toBe('waiting'); + expect(waitResult.waitTill).toEqual(runExecutionData.waitTill); + + // Test execution with error + const testError = new Error('Test error') as ExecutionBaseError; + + // Reset the status since it was changed by previous tests + // @ts-expect-error read-only property + workflowExecute.status = 'new'; + runExecutionData.waitTill = undefined; + + const errorResult = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + testError, + ); + + expect(errorResult.data.resultData.error).toBeDefined(); + expect(errorResult.data.resultData.error?.message).toBe('Test error'); + + // Test canceled execution + const cancelError = new Error('Workflow execution canceled') as ExecutionBaseError; + const cancelResult = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + cancelError, + ); + expect(cancelResult.data.resultData.error).toBeDefined(); + expect(cancelResult.data.resultData.error?.message).toBe('Workflow execution canceled'); + }); + + test('should handle static data, hooks, and cleanup correctly', async () => { + // Mock static data change + workflow.staticData.__dataChanged = true; + workflow.staticData.testData = 'changed'; + + // Mock cleanup function that's actually a promise + let cleanupCalled = false; + const mockCleanupPromise = new Promise((resolve) => { + setTimeout(() => { + cleanupCalled = true; + resolve(); + }, 0); + }); + + const result = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + undefined, + mockCleanupPromise, + ); + + // Verify static data handling + expect(result).toBeDefined(); + expect(workflowExecute.moveNodeMetadata).toHaveBeenCalled(); + expect(workflowExecute.executeHook).toHaveBeenCalledWith('workflowExecuteAfter', [ + result, + workflow.staticData, + ]); + + // Verify cleanup was called + await mockCleanupPromise; + expect(cleanupCalled).toBe(true); + }); + }); + + describe('assignPairedItems', () => { + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + workflowExecute = new WorkflowExecute(mock(), 'manual'); + }); + + test('should handle undefined node output', () => { + const result = workflowExecute.assignPairedItems( + undefined, + mock({ data: { main: [] } }), + ); + expect(result).toBeNull(); + }); + + test('should auto-fix pairedItem for single input/output scenario', () => { + const nodeOutput = [[{ json: { test: true } }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + }); + + test('should auto-fix pairedItem when number of items match', () => { + const nodeOutput = [[{ json: { test: 1 } }, { json: { test: 2 } }]]; + const executionData = mock({ + data: { main: [[{ json: { input: 1 } }, { json: { input: 2 } }]] }, + }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + expect(result?.[0][1].pairedItem).toEqual({ item: 1 }); + }); + + test('should not modify existing pairedItem data', () => { + const existingPairedItem = { item: 5, input: 2 }; + const nodeOutput = [[{ json: { test: true }, pairedItem: existingPairedItem }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual(existingPairedItem); + }); + + test('should process multiple output branches correctly', () => { + const nodeOutput = [[{ json: { test: 1 } }], [{ json: { test: 2 } }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + expect(result?.[1][0].pairedItem).toEqual({ item: 0 }); + }); + }); }); diff --git a/packages/core/test/error-reporter.test.ts b/packages/core/test/error-reporter.test.ts index 1f507ab5c0081..9edc27f15ccd9 100644 --- a/packages/core/test/error-reporter.test.ts +++ b/packages/core/test/error-reporter.test.ts @@ -1,9 +1,11 @@ import { QueryFailedError } from '@n8n/typeorm'; import type { ErrorEvent } from '@sentry/types'; import { AxiosError } from 'axios'; +import { mock } from 'jest-mock-extended'; import { ApplicationError } from 'n8n-workflow'; import { ErrorReporter } from '@/error-reporter'; +import type { Logger } from '@/logging/logger'; jest.mock('@sentry/node', () => ({ init: jest.fn(), @@ -15,7 +17,7 @@ jest.mock('@sentry/node', () => ({ jest.spyOn(process, 'on'); describe('ErrorReporter', () => { - const errorReporter = new ErrorReporter(); + const errorReporter = new ErrorReporter(mock()); const event = {} as ErrorEvent; describe('beforeSend', () => { @@ -100,4 +102,29 @@ describe('ErrorReporter', () => { expect(result).toBeNull(); }); }); + + describe('error', () => { + let error: ApplicationError; + let logger: Logger; + let errorReporter: ErrorReporter; + const metadata = undefined; + + beforeEach(() => { + error = new ApplicationError('Test error'); + logger = mock(); + errorReporter = new ErrorReporter(logger); + }); + + it('should include stack trace for error-level `ApplicationError`', () => { + error.level = 'error'; + errorReporter.error(error); + expect(logger.error).toHaveBeenCalledWith(`Test error\n${error.stack}\n`, metadata); + }); + + it('should exclude stack trace for warning-level `ApplicationError`', () => { + error.level = 'warning'; + errorReporter.error(error); + expect(logger.error).toHaveBeenCalledWith('Test error', metadata); + }); + }); }); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 401bb177c4cc5..654ae1be613ea 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,6 +15,7 @@ "include": ["src/**/*.ts", "test/**/*.ts"], "references": [ { "path": "../workflow/tsconfig.build.json" }, + { "path": "../@n8n/config/tsconfig.build.json" }, { "path": "../@n8n/client-oauth2/tsconfig.build.json" } ] } diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d4a7e1dcec9db..2ecd0513acb33 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -45,6 +45,7 @@ "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", "element-plus": "2.4.3", + "is-emoji-supported": "^0.0.5", "markdown-it": "^13.0.2", "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", @@ -55,5 +56,8 @@ "vue-boring-avatars": "^1.3.0", "vue-router": "catalog:frontend", "xss": "catalog:" + }, + "peerDependencies": { + "@vueuse/core": "*" } } diff --git a/packages/design-system/src/__tests__/setup.ts b/packages/design-system/src/__tests__/setup.ts index 981c9d5a60835..5c091e2925e75 100644 --- a/packages/design-system/src/__tests__/setup.ts +++ b/packages/design-system/src/__tests__/setup.ts @@ -15,3 +15,8 @@ window.ResizeObserver = observe: vi.fn(), unobserve: vi.fn(), })); + +// Globally mock is-emoji-supported +vi.mock('is-emoji-supported', () => ({ + isEmojiSupported: () => true, +})); diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index a43d7a6d7e35b..02de38143cc22 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -13,6 +13,7 @@ interface ActionBoxProps { buttonText: string; buttonType: ButtonType; buttonDisabled?: boolean; + buttonIcon?: string; description: string; calloutText?: string; calloutTheme?: CalloutTheme; @@ -22,6 +23,7 @@ interface ActionBoxProps { defineOptions({ name: 'N8nActionBox' }); withDefaults(defineProps(), { calloutTheme: 'info', + buttonIcon: undefined, }); @@ -51,6 +53,7 @@ withDefaults(defineProps(), { :label="buttonText" :type="buttonType" :disabled="buttonDisabled" + :icon="buttonIcon" size="large" @click="$emit('click:button', $event)" /> diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts new file mode 100644 index 0000000000000..b03a0cc3326f0 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts @@ -0,0 +1,57 @@ +import { action } from '@storybook/addon-actions'; +import type { StoryFn } from '@storybook/vue3'; + +import { TEST_ICONS } from './constants'; +import type { Icon } from './IconPicker.vue'; +import N8nIconPicker from './IconPicker.vue'; + +export default { + title: 'Atoms/Icon Picker', + component: N8nIconPicker, + argTypes: { + buttonTooltip: { + control: 'text', + }, + buttonSize: { + type: 'select', + options: ['small', 'large'], + }, + }, +}; + +function createTemplate(icon: Icon): StoryFn { + return (args, { argTypes }) => ({ + components: { N8nIconPicker }, + props: Object.keys(argTypes), + setup: () => ({ args }), + data: () => ({ + icon, + }), + template: + '
', + methods: { + onIconSelected: action('iconSelected'), + }, + }); +} + +const DefaultTemplate = createTemplate({ type: 'icon', value: 'smile' }); +export const Default = DefaultTemplate.bind({}); +Default.args = { + buttonTooltip: 'Select an icon', + availableIcons: TEST_ICONS, +}; + +const CustomTooltipTemplate = createTemplate({ type: 'icon', value: 'layer-group' }); +export const WithCustomIconAndTooltip = CustomTooltipTemplate.bind({}); +WithCustomIconAndTooltip.args = { + availableIcons: [...TEST_ICONS], + buttonTooltip: 'Select something...', +}; + +const OnlyEmojiTemplate = createTemplate({ type: 'emoji', value: '🔥' }); +export const OnlyEmojis = OnlyEmojiTemplate.bind({}); +OnlyEmojis.args = { + buttonTooltip: 'Select an emoji', + availableIcons: [], +}; diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts new file mode 100644 index 0000000000000..f3295ba5e67d3 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts @@ -0,0 +1,183 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render } from '@testing-library/vue'; +import { createRouter, createWebHistory } from 'vue-router'; + +import IconPicker from '.'; +import { TEST_ICONS } from './constants'; + +// Create a proxy handler that returns a mock icon object for any icon name +// and mock the entire icon library with the proxy +vi.mock( + '@fortawesome/free-solid-svg-icons', + () => + new Proxy( + {}, + { + get: (_target, prop) => { + return { prefix: 'fas', iconName: prop.toString().replace('fa', '').toLowerCase() }; + }, + }, + ), +); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/icons', + name: 'icons', + redirect: '/icons', + }, + { + path: '/emojis', + name: 'emojis', + component: { template: '

emojis

' }, + }, + ], +}); + +// Component stubs +const components = { + N8nIconButton: { + template: '