import { createWorkflowWithHistory, testDb, mockInstance, createActiveWorkflow, createTeamProject, linkUserToProject, createWorkflow, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { SharedWorkflowRepository, type WorkflowEntity, WorkflowPublishedVersionRepository, WorkflowPublishHistoryRepository, WorkflowRepository, ProjectRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { INode } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { NodeTypes } from '@/node-types'; import { Telemetry } from '@/telemetry'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowValidationService } from '@/workflows/workflow-validation.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles'; import { createOwner, createMember } from '../shared/db/users'; import { createWorkflowHistoryItem } from '../shared/db/workflow-history'; import { WebhookService } from '@/webhooks/webhook.service'; let globalConfig: GlobalConfig; let workflowRepository: WorkflowRepository; let workflowService: WorkflowService; let workflowPublishedVersionRepository: WorkflowPublishedVersionRepository; let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository; let workflowHistoryService: WorkflowHistoryService; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); const workflowValidationService = mockInstance(WorkflowValidationService); const nodeTypes = mockInstance(NodeTypes); const webhookServiceMock = mockInstance(WebhookService); mockInstance(MessageEventBus); mockInstance(Telemetry); beforeAll(async () => { await testDb.init(); globalConfig = Container.get(GlobalConfig); workflowRepository = Container.get(WorkflowRepository); workflowPublishedVersionRepository = Container.get(WorkflowPublishedVersionRepository); workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository); workflowHistoryService = Container.get(WorkflowHistoryService); workflowService = new WorkflowService( mock(), Container.get(SharedWorkflowRepository), workflowRepository, mock(), mock(), Container.get(OwnershipService), // ownershipService mock(), workflowHistoryService, mock(), activeWorkflowManager, Container.get(RoleService), // roleService Container.get(ProjectService), // projectService mock(), // executionRepository mock(), // eventService globalConfig, mock(), Container.get(WorkflowFinderService), workflowPublishedVersionRepository, workflowPublishHistoryRepository, workflowValidationService, nodeTypes, webhookServiceMock, mock(), // licenseState Container.get(ProjectRepository), // projectRepository ); }); beforeEach(() => { workflowValidationService.validateForActivation.mockReturnValue({ isValid: true }); workflowValidationService.validateDynamicCredentials.mockResolvedValue({ isValid: true }); workflowValidationService.validateSubWorkflowReferences.mockResolvedValue({ isValid: true }); webhookServiceMock.findWebhookConflicts.mockReset(); webhookServiceMock.findWebhookConflicts.mockResolvedValue([]); }); afterEach(async () => { await testDb.truncate([ 'SharedWorkflow', 'ProjectRelation', 'WorkflowPublishedVersion', 'WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory', 'Project', 'User', ]); await cleanupRolesAndScopes(); jest.restoreAllMocks(); }); describe('update()', () => { test('should save workflow history version with backfilled data when nodes change', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); const updateData = { nodes: [ { id: 'new-node', name: 'New Node', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300], parameters: {}, }, ], }; await workflowService.update(owner, updateData as WorkflowEntity, workflow.id, { forceSave: true, }); expect(saveVersionSpy).toHaveBeenCalledTimes(1); const [user, workflowData, workflowId] = saveVersionSpy.mock.calls[0]; expect(user).toBe(owner); expect(workflowId).toBe(workflow.id); expect(workflowData.nodes).toEqual(updateData.nodes); // Verify that connections were backfilled from the DB expect(workflowData.connections).toEqual(workflow.connections); expect(workflowData.versionId).not.toBe(workflow.versionId); expect(addRecordSpy).not.toBeCalled(); }); test('should save workflow history version with backfilled data when connection change', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory( { nodes: [ { id: 'uuid-1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [240, 300], parameters: {}, }, { id: 'uuid-2', name: 'Code Node', type: 'n8n-nodes-base.code', typeVersion: 1, position: [500, 300], parameters: {}, }, ], }, owner, ); const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); const updateData = { connections: { 'Manual Trigger': { main: [ [ { node: 'Code Node', type: 'main', index: 0, }, ], ], }, }, }; await workflowService.update(owner, updateData as unknown as WorkflowEntity, workflow.id, { forceSave: true, }); expect(saveVersionSpy).toHaveBeenCalledTimes(1); const [user, workflowData, workflowId] = saveVersionSpy.mock.calls[0]; expect(user).toBe(owner); expect(workflowId).toBe(workflow.id); expect(workflowData.connections).toEqual(updateData.connections); // Verify that nodes were backfilled from the DB expect(workflowData.nodes).toEqual(workflow.nodes); expect(workflowData.versionId).not.toBe(workflow.versionId); expect(addRecordSpy).not.toBeCalled(); }); }); describe('activateWorkflow()', () => { test('should activate current workflow version if no version provided', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id); expect(updatedWorkflow.active).toBe(true); expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId); expect(updatedWorkflow.activeVersion).toBeDefined(); expect(updatedWorkflow.activeVersion?.workflowPublishHistory).toHaveLength(1); expect(updatedWorkflow.activeVersion?.workflowPublishHistory[0]).toMatchObject({ event: 'activated', versionId: workflow.versionId, }); expect(addRecordSpy).toBeCalledWith({ event: 'activated', workflowId: workflow.id, versionId: workflow.versionId, userId: owner.id, }); }); test('should activate the provided workflow version', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const newVersionId = uuid(); await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId, }); expect(updatedWorkflow.active).toBe(true); expect(updatedWorkflow.activeVersionId).toBe(newVersionId); expect(updatedWorkflow.versionId).toBe(workflow.versionId); expect(updatedWorkflow.activeVersion?.workflowPublishHistory).toHaveLength(1); expect(updatedWorkflow.activeVersion?.workflowPublishHistory[0]).toMatchObject({ event: 'activated', versionId: newVersionId, }); expect(addRecordSpy).toBeCalledWith({ event: 'activated', workflowId: workflow.id, versionId: newVersionId, userId: owner.id, }); }); test('should throw an error when webhook conflicts were found', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); const newVersionId = uuid(); await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); webhookServiceMock.findWebhookConflicts.mockResolvedValue([ { trigger: { id: '', name: '', typeVersion: 0, type: '', position: [1, 2], parameters: {}, }, conflict: { webhookId: 'some-id', webhookPath: 'some-path', workflowId: 'workflow-123', method: 'GET', }, }, ]); await expect( workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId, }), ).rejects.toThrow('There is a conflict with one of the webhooks.'); }); test('should use nodes from correct workflow version when checking conflicts and versionId is passed', async () => { const owner = await createOwner(); const oldVersionId = uuid(); const oldNodes: INode[] = [ { id: '123', webhookId: 'version1', name: 'test', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, { id: '345', webhookId: 'version1-2', name: 'test2', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, ]; const workflow = await createWorkflowWithHistory( { nodes: oldNodes, versionId: oldVersionId, }, owner, ); const newVersionId = uuid(); const newNodes: INode[] = [ { id: '123', webhookId: 'version2', name: 'updatedNode', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, ]; await workflowService.update( owner, { nodes: newNodes, } as WorkflowEntity, workflow.id, ); await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId, nodes: [ { id: '123', webhookId: 'version2', name: 'newNode', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, ], }); await workflowService.activateWorkflow(owner, workflow.id, { versionId: oldVersionId, }); expect(webhookServiceMock.findWebhookConflicts.mock.calls[0][0].nodes).toEqual( oldNodes.reduce((res, node) => ({ ...res, [node.name]: node }), {}), ); }); test('should use nodes from latest workflow version when checking conflicts and no versionId is passed', async () => { const owner = await createOwner(); const oldNodes: INode[] = [ { id: '123', webhookId: 'version1', name: 'test', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, { id: '345', webhookId: 'version1-2', name: 'test2', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, ]; const workflow = await createWorkflowWithHistory( { nodes: oldNodes, versionId: uuid(), }, owner, ); const newNodes: INode[] = [ { id: '123', webhookId: 'version2', name: 'newNode', typeVersion: 0, type: 'n8n-nodes-base.webhook', position: [1, 2], parameters: {}, }, ]; await workflowService.update( owner, { nodes: newNodes, } as WorkflowEntity, workflow.id, ); await workflowService.activateWorkflow(owner, workflow.id, {}); expect(webhookServiceMock.findWebhookConflicts.mock.calls[0][0].nodes).toEqual( newNodes.reduce((res, node) => ({ ...res, [node.name]: node }), {}), ); }); test('should not activate workflow if validation fails and keep old active version', async () => { const owner = await createOwner(); const workflow = await createActiveWorkflow({}, owner); const oldActiveVersionId = workflow.activeVersionId; const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); // Create a new version to try to activate const newVersionId = uuid(); await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); // Mock validation to fail workflowValidationService.validateForActivation.mockReturnValue({ isValid: false, error: 'Workflow cannot be activated because it has no trigger node.', }); await expect( workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId, }), ).rejects.toThrow('Workflow cannot be activated because it has no trigger node.'); // Verify no publish history was added expect(addRecordSpy).not.toBeCalled(); // Verify the workflow still has the old active version const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } }); expect(workflowAfter?.activeVersionId).toBe(oldActiveVersionId); expect(workflowAfter?.active).toBe(true); }); test('should not activate workflow without workflow:publish permission', async () => { const owner = await createOwner(); const member = await createMember(); // custom role with workflow:update but not workflow:publish const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], { roleType: 'project', displayName: 'Custom Workflow Updater', description: 'Can update workflows but not publish them', }); const project = await createTeamProject('Test Project', owner); await linkUserToProject(member, project, customRole.slug); const workflow = await createWorkflowWithHistory({}, project); await expect(workflowService.activateWorkflow(member, workflow.id)).rejects.toThrow( 'You do not have permission to activate this workflow. Ask the owner to share it with you.', ); const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } }); expect(workflowAfter?.active).toBe(false); expect(workflowAfter?.activeVersionId).toBeNull(); }); }); describe('deactivateWorkflow()', () => { test('should not deactivate workflow without workflow:unpublish permission', async () => { const owner = await createOwner(); const member = await createMember(); // custom role with workflow:update but not workflow:unpublish const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], { roleType: 'project', displayName: 'Custom Workflow Updater', description: 'Can update workflows but not unpublish them', }); const project = await createTeamProject('Test Project', owner); await linkUserToProject(member, project, customRole.slug); const workflow = await createActiveWorkflow({}, project); await expect(workflowService.deactivateWorkflow(member, workflow.id)).rejects.toThrow( 'You do not have permission to deactivate this workflow. Ask the owner to share it with you.', ); // Verify workflow is still active const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } }); expect(workflowAfter?.active).toBe(true); expect(workflowAfter?.activeVersionId).toBe(workflow.activeVersionId); }); }); describe('workflow_published_version table population', () => { describe('when feature flag is enabled', () => { beforeEach(() => { globalConfig.workflows.useWorkflowPublicationService = true; }); afterEach(() => { globalConfig.workflows.useWorkflowPublicationService = false; }); test('should write to workflow_published_version on activation', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); const publishedVersion = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersion?.publishedVersionId).toBe(workflow.versionId); }); test('should update workflow_published_version when activating a new version', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); const newVersionId = uuid(); await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); await workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId, }); const publishedVersion = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersion?.publishedVersionId).toBe(newVersionId); }); test('should remove workflow_published_version on deactivation', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); await workflowService.deactivateWorkflow(owner, workflow.id); const publishedVersion = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersion).toBeNull(); }); test('should remove workflow_published_version on archive', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); const publishedVersionBefore = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersionBefore).not.toBeNull(); await workflowService.archive(owner, workflow.id); const publishedVersionAfter = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersionAfter).toBeNull(); }); }); describe('when feature flag is disabled', () => { test('should not write to workflow_published_version on activation', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); const publishedVersion = await workflowPublishedVersionRepository.findOne({ where: { workflowId: workflow.id }, }); expect(publishedVersion).toBeNull(); }); test('should not write to workflow_published_version on deactivation', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); await workflowService.activateWorkflow(owner, workflow.id); await workflowService.deactivateWorkflow(owner, workflow.id); const count = await workflowPublishedVersionRepository.count(); expect(count).toBe(0); }); }); }); describe('getMany()', () => { describe('filtering by personal project', () => { test('should return empty when regular user queries another users personal project', async () => { const member1 = await createMember(); const member2 = await createMember(); const projectRepository = Container.get(ProjectRepository); const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member2.id, ); // member2 owns some workflows in their personal project await createWorkflow({ name: 'Member2 Private Workflow 1' }, member2); await createWorkflow({ name: 'Member2 Private Workflow 2' }, member2); // member1 (who has NO relation to member2's personal project) tries to query member2's personal project const result = await workflowService.getMany( member1, { filter: { projectId: member2PersonalProject.id } }, false, false, false, ); // SECURITY: member1 should NOT see any of member2's workflows expect(result.workflows).toHaveLength(0); expect(result.count).toBe(0); }); test('should allow admin with global workflow:read to query another users personal project', async () => { const owner = await createOwner(); // Owner has global workflow:read scope const member = await createMember(); const projectRepository = Container.get(ProjectRepository); const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member.id, ); // member owns some workflows in their personal project const workflow1 = await createWorkflow({ name: 'Member Private Workflow 1' }, member); const workflow2 = await createWorkflow({ name: 'Member Private Workflow 2' }, member); // owner (with global workflow:read) can query member's personal project const result = await workflowService.getMany( owner, { filter: { projectId: memberPersonalProject.id } }, false, false, false, ); // Admin with global scope CAN see the workflows expect(result.workflows).toHaveLength(2); expect(result.count).toBe(2); const workflowIds = result.workflows.map((w) => w.id).sort(); expect(workflowIds).toEqual([workflow1.id, workflow2.id].sort()); }); test('should return only workflows owned by user in their personal project', async () => { const owner = await createOwner(); const member = await createMember(); const projectRepository = Container.get(ProjectRepository); const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member.id, ); const memberOwnedWorkflow = await createWorkflow({ name: 'Member Owned Workflow' }, member); const sharedWorkflow = await createWorkflow({ name: 'Shared Workflow' }, owner); await Container.get(SharedWorkflowRepository).save( Container.get(SharedWorkflowRepository).create({ projectId: memberPersonalProject.id, workflowId: sharedWorkflow.id, role: 'workflow:editor', }), ); const result = await workflowService.getMany( owner, { filter: { projectId: memberPersonalProject.id } }, false, false, false, ); expect(result.workflows).toHaveLength(1); expect(result.workflows[0].id).toBe(memberOwnedWorkflow.id); expect(result.workflows[0].name).toBe('Member Owned Workflow'); expect(result.count).toBe(1); }); test('should return empty when filtering by personal project of user with no owned workflows', async () => { const owner = await createOwner(); const member = await createMember(); const projectRepository = Container.get(ProjectRepository); const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member.id, ); const sharedWorkflow = await createWorkflow({ name: 'Shared Workflow' }, owner); await Container.get(SharedWorkflowRepository).save( Container.get(SharedWorkflowRepository).create({ projectId: memberPersonalProject.id, workflowId: sharedWorkflow.id, role: 'workflow:editor', }), ); const result = await workflowService.getMany( owner, { filter: { projectId: memberPersonalProject.id } }, false, false, false, ); expect(result.workflows).toHaveLength(0); expect(result.count).toBe(0); }); test('should return empty when filtering by non-existent project', async () => { const owner = await createOwner(); const result = await workflowService.getMany( owner, { filter: { projectId: 'non-existent-project-id' } }, false, false, false, ); expect(result.workflows).toHaveLength(0); expect(result.count).toBe(0); }); test('should return user owned workflows when user queries their own personal project', async () => { const member = await createMember(); const projectRepository = Container.get(ProjectRepository); const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member.id, ); const workflow1 = await createWorkflow({ name: 'Workflow 1' }, member); const workflow2 = await createWorkflow({ name: 'Workflow 2' }, member); const result = await workflowService.getMany( member, { filter: { projectId: memberPersonalProject.id } }, false, false, false, ); expect(result.workflows).toHaveLength(2); expect(result.count).toBe(2); const workflowIds = result.workflows.map((w) => w.id).sort(); expect(workflowIds).toEqual([workflow1.id, workflow2.id].sort()); }); test('should handle team project filtering correctly', async () => { const owner = await createOwner(); const member = await createMember(); const teamProject = await createTeamProject('Team Project', owner); await linkUserToProject(member, teamProject, 'project:editor'); const teamWorkflow1 = await createWorkflow({ name: 'Team Workflow 1' }, teamProject); const teamWorkflow2 = await createWorkflow({ name: 'Team Workflow 2' }, teamProject); const result = await workflowService.getMany( member, { filter: { projectId: teamProject.id } }, false, false, false, ); expect(result.workflows).toHaveLength(2); expect(result.count).toBe(2); const workflowIds = result.workflows.map((w) => w.id).sort(); expect(workflowIds).toEqual([teamWorkflow1.id, teamWorkflow2.id].sort()); }); }); });