diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index a14cb091dc..8a432ecea7 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -2,6 +2,7 @@ import type express from 'express'; import { Container } from 'typedi'; import type { FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; +import { v4 as uuid } from 'uuid'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import config from '@/config'; @@ -36,6 +37,7 @@ export = { const workflow = req.body; workflow.active = false; + workflow.versionId = uuid(); await replaceInvalidCredentials(workflow); @@ -45,6 +47,14 @@ export = { const createdWorkflow = await createWorkflow(workflow, req.user, role); + if (isWorkflowHistoryLicensed()) { + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + createdWorkflow, + createdWorkflow.id, + ); + } + await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, true); @@ -151,6 +161,7 @@ export = { const updateData = new WorkflowEntity(); Object.assign(updateData, req.body); updateData.id = id; + updateData.versionId = uuid(); const sharedWorkflow = await getSharedWorkflow(req.user, id); @@ -179,10 +190,6 @@ export = { } } - if (isWorkflowHistoryLicensed()) { - await Container.get(WorkflowHistoryService).saveVersion(req.user, sharedWorkflow.workflow); - } - if (sharedWorkflow.workflow.active) { try { await workflowRunner.add(sharedWorkflow.workflowId, 'update'); @@ -195,6 +202,14 @@ export = { const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId); + if (isWorkflowHistoryLicensed() && updatedWorkflow) { + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + updatedWorkflow, + sharedWorkflow.workflowId, + ); + } + await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index fffe2f4c8c..a8f6e59b46 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -64,14 +64,14 @@ export class WorkflowHistoryService { return hist; } - async saveVersion(user: User, workflow: WorkflowEntity) { + async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) { if (isWorkflowHistoryEnabled()) { await this.workflowHistoryRepository.insert({ authors: user.firstName + ' ' + user.lastName, connections: workflow.connections, nodes: workflow.nodes, versionId: workflow.versionId, - workflowId: workflow.id, + workflowId, }); } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index aa0f5dcc68..80732655ae 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -26,6 +26,8 @@ import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; import { TagService } from '@/services/tag.service'; +import { isWorkflowHistoryLicensed } from './workflowHistory/workflowHistoryHelper.ee'; +import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; export const workflowsController = express.Router(); @@ -99,6 +101,14 @@ workflowsController.post( throw new ResponseHelper.InternalServerError('Failed to save workflow'); } + if (isWorkflowHistoryLicensed()) { + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + savedWorkflow, + savedWorkflow.id, + ); + } + if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { savedWorkflow.tags = Container.get(TagService).sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index c6115a1f3d..a9cd3aeb77 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -301,8 +301,8 @@ export class WorkflowsService { ); } - if (isWorkflowHistoryLicensed()) { - await Container.get(WorkflowHistoryService).saveVersion(user, shared.workflow); + if (isWorkflowHistoryLicensed() && workflow.versionId !== shared.workflow.versionId) { + await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId); } const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 8fe1291bba..c23c1f3213 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -10,6 +10,9 @@ import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; +import { License } from '@/License'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; +import Container from 'typedi'; let workflowOwnerRole: Role; let owner: User; @@ -20,6 +23,11 @@ let workflowRunner: ActiveWorkflowRunner; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + beforeAll(async () => { const [globalOwnerRole, globalMemberRole, fetchedWorkflowOwnerRole] = await testDb.getAllRoles(); @@ -40,10 +48,18 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Tag', 'Workflow', 'Credentials']); + await testDb.truncate([ + 'SharedCredentials', + 'SharedWorkflow', + 'Tag', + 'Workflow', + 'Credentials', + WorkflowHistoryRepository, + ]); authOwnerAgent = testServer.publicApiAgentFor(owner); authMemberAgent = testServer.publicApiAgentFor(member); + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); }); afterEach(async () => { @@ -678,6 +694,90 @@ describe('POST /workflows', () => { expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); }); + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { id } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { id } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); + test('should not add a starting node if the payload has no starting nodes', async () => { const response = await authMemberAgent.post('/workflows').send({ name: 'testing', @@ -834,6 +934,108 @@ describe('PUT /workflows/:id', () => { ); }); + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const workflow = await testDb.createWorkflow({}, member); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, member); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); + test('should update non-owned workflow if owner', async () => { const workflow = await testDb.createWorkflow({}, member); diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index fb56237fd7..cb49e8d9a1 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -11,6 +11,8 @@ import { v4 as uuid } from 'uuid'; import { RoleService } from '@/services/role.service'; import Container from 'typedi'; import type { ListQuery } from '@/requests'; +import { License } from '@/License'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -20,13 +22,19 @@ const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); const { objectContaining, arrayContaining, any } = expect; +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + beforeAll(async () => { owner = await testDb.createOwner(); authOwnerAgent = testServer.authAgentFor(owner); }); beforeEach(async () => { - await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag']); + await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag', WorkflowHistoryRepository]); + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); }); describe('POST /workflows', () => { @@ -46,6 +54,96 @@ describe('POST /workflows', () => { const pinData = await testWithPinData(false); expect(pinData).toBeNull(); }); + + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); }); describe('GET /workflows/:id', () => { @@ -318,3 +416,111 @@ describe('GET /workflows', () => { }); }); }); + +describe('PATCH /workflows/:id', () => { + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); +});