diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 817d6321a944b..2c6079203aaa9 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -35,7 +35,7 @@ export type CommunityPackageScope = ResourceScope< 'communityPackage', 'install' | 'uninstall' | 'update' | 'list' | 'manage' >; -export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share'>; +export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share' | 'move'>; export type ExternalSecretScope = ResourceScope<'externalSecret', 'list' | 'use'>; export type ExternalSecretProviderScope = ResourceScope< 'externalSecretsProvider', @@ -58,7 +58,10 @@ export type TagScope = ResourceScope<'tag'>; export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>; export type VariableScope = ResourceScope<'variable'>; export type WorkersViewScope = ResourceScope<'workersView', 'manage'>; -export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share' | 'execute'>; +export type WorkflowScope = ResourceScope< + 'workflow', + DefaultOperations | 'share' | 'execute' | 'move' +>; export type Scope = | AuditLogsScope diff --git a/packages/cli/src/errors/response-errors/not-found.error.ts b/packages/cli/src/errors/response-errors/not-found.error.ts index 9d9e0e12d576a..6bbcea9581d12 100644 --- a/packages/cli/src/errors/response-errors/not-found.error.ts +++ b/packages/cli/src/errors/response-errors/not-found.error.ts @@ -1,6 +1,16 @@ import { ResponseError } from './abstract/response.error'; export class NotFoundError extends ResponseError { + static isDefinedAndNotNull( + value: T | undefined | null, + message: string, + hint?: string, + ): asserts value is T { + if (value === undefined || value === null) { + throw new NotFoundError(message, hint); + } + } + constructor(message: string, hint: string | undefined = undefined) { super(message, 404, 404, hint); } diff --git a/packages/cli/src/errors/response-errors/transfer-workflow.error.ts b/packages/cli/src/errors/response-errors/transfer-workflow.error.ts new file mode 100644 index 0000000000000..c22c073089094 --- /dev/null +++ b/packages/cli/src/errors/response-errors/transfer-workflow.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class TransferWorkflowError extends ResponseError { + constructor(message: string) { + super(message, 400, 400); + } +} diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions/global-roles.ts index 9a138d1f3817e..9824ec1bee062 100644 --- a/packages/cli/src/permissions/global-roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -9,6 +9,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'credential:delete', 'credential:list', 'credential:share', + 'credential:move', 'communityPackage:install', 'communityPackage:uninstall', 'communityPackage:update', @@ -68,6 +69,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'workflow:list', 'workflow:share', 'workflow:execute', + 'workflow:move', 'workersView:manage', 'project:list', 'project:create', diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts index 3c649fb5e0f8c..159a2af45246a 100644 --- a/packages/cli/src/permissions/project-roles.ts +++ b/packages/cli/src/permissions/project-roles.ts @@ -13,11 +13,13 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:list', 'workflow:execute', + 'workflow:move', 'credential:create', 'credential:read', 'credential:update', 'credential:delete', 'credential:list', + 'credential:move', 'project:list', 'project:read', 'project:update', @@ -32,12 +34,14 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'workflow:list', 'workflow:execute', 'workflow:share', + 'workflow:move', 'credential:create', 'credential:read', 'credential:update', 'credential:delete', 'credential:list', 'credential:share', + 'credential:move', 'project:list', 'project:read', ]; diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions/resource-roles.ts index 429242a0c75d5..e1be52f430fdf 100644 --- a/packages/cli/src/permissions/resource-roles.ts +++ b/packages/cli/src/permissions/resource-roles.ts @@ -5,6 +5,7 @@ export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ 'credential:update', 'credential:delete', 'credential:share', + 'credential:move', ]; export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; @@ -15,6 +16,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:execute', 'workflow:share', + 'workflow:move', ]; export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 017e90606fbd3..0378857ca39f3 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -54,5 +54,11 @@ export declare namespace WorkflowRequest { type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>; + type Transfer = AuthenticatedRequest< + { workflowId: string }, + {}, + { destinationProjectId: string } + >; + type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>; } diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 250b6e60155c3..62b4acd27b0dc 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import omit from 'lodash/omit'; -import { ApplicationError, NodeOperationError } from 'n8n-workflow'; +import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n8n-workflow'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; @@ -20,6 +20,10 @@ import type { import { OwnershipService } from '@/services/ownership.service'; import { In, type EntityManager } from '@n8n/typeorm'; import { Project } from '@/databases/entities/Project'; +import { ProjectService } from '@/services/project.service'; +import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; +import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; @Service() export class EnterpriseWorkflowService { @@ -30,6 +34,8 @@ export class EnterpriseWorkflowService { private readonly credentialsRepository: CredentialsRepository, private readonly credentialsService: CredentialsService, private readonly ownershipService: OwnershipService, + private readonly projectService: ProjectService, + private readonly activeWorkflowManager: ActiveWorkflowManager, ) {} async shareWithProjects( @@ -235,4 +241,100 @@ export class EnterpriseWorkflowService { ); }); } + + async transferOne(user: User, workflowId: string, destinationProjectId: string) { + // 1. get workflow + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:move', + ]); + NotFoundError.isDefinedAndNotNull( + workflow, + `Could not find workflow with the id "${workflowId}". Make sure you have the permission to delete it.`, + ); + + // 2. get owner-sharing + const ownerSharing = workflow.shared.find((s) => s.role === 'workflow:owner')!; + NotFoundError.isDefinedAndNotNull( + ownerSharing, + `Could not find owner for workflow ${workflow.id}`, + ); + + // 3. get source project + const sourceProject = ownerSharing.project; + + // 4. get destination project + const destinationProject = await this.projectService.getProjectWithScope( + user, + destinationProjectId, + ['workflow:create'], + ); + NotFoundError.isDefinedAndNotNull( + destinationProject, + `Could not find project with the id "${destinationProjectId}". Make sure you have the permission to create workflows in it.`, + ); + + // 5. checks + if (sourceProject.id === destinationProject.id) { + throw new TransferWorkflowError( + "You can't transfer a workflow into the project that's already owning it.", + ); + } + if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') { + throw new TransferWorkflowError( + 'You can only transfer workflows out of personal or team projects.', + ); + } + if (destinationProject.type !== 'team') { + throw new TransferWorkflowError('You can only transfer workflows into team projects.'); + } + + // 6. deactivate workflow if necessary + const wasActive = workflow.active; + if (wasActive) { + await this.activeWorkflowManager.remove(workflowId); + } + + // 7. transfer the workflow + await this.workflowRepository.manager.transaction(async (trx) => { + // remove all sharings + await trx.remove(workflow.shared); + + // create new owner-sharing + await trx.save( + trx.create(SharedWorkflow, { + workflowId: workflow.id, + projectId: destinationProject.id, + role: 'workflow:owner', + }), + ); + }); + + // 8. try to activate it again if it was active + if (wasActive) { + try { + await this.activeWorkflowManager.add(workflowId, 'update'); + + return; + } catch (error) { + await this.workflowRepository.updateActiveState(workflowId, false); + + // Since the transfer worked we return a 200 but also return the + // activation error as data. + if (error instanceof WorkflowActivationError) { + return { + error: error.toJSON + ? error.toJSON() + : { + name: error.name, + message: error.message, + }, + }; + } + + throw error; + } + } + + return; + } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 497ff6edc97c7..a33194a31e545 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -40,6 +40,7 @@ import { ApplicationError } from 'n8n-workflow'; import { In, type FindOptionsRelations } from '@n8n/typeorm'; import type { Project } from '@/databases/entities/Project'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { z } from 'zod'; @RestController('/workflows') export class WorkflowsController { @@ -460,4 +461,16 @@ export class WorkflowsController { workflow, }); } + + @Put('/:workflowId/transfer') + @ProjectScope('workflow:move') + async transfer(req: WorkflowRequest.Transfer) { + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); + + return await this.enterpriseWorkflowService.transferOne( + req.user, + req.params.workflowId, + body.destinationProjectId, + ); + } } diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 9b238d51c950b..454ac3ac0920c 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -145,7 +145,7 @@ describe('GET /credentials', () => { // Team cred expect(cred1.id).toBe(savedCredential1.id); expect(cred1.scopes).toEqual( - ['credential:read', 'credential:update', 'credential:delete'].sort(), + ['credential:move', 'credential:read', 'credential:update', 'credential:delete'].sort(), ); // Shared cred @@ -172,7 +172,13 @@ describe('GET /credentials', () => { // Shared cred expect(cred2.id).toBe(savedCredential2.id); expect(cred2.scopes).toEqual( - ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + [ + 'credential:delete', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ].sort(), ); } @@ -191,11 +197,12 @@ describe('GET /credentials', () => { expect(cred1.scopes).toEqual( [ 'credential:create', - 'credential:read', - 'credential:update', 'credential:delete', 'credential:list', + 'credential:move', + 'credential:read', 'credential:share', + 'credential:update', ].sort(), ); @@ -204,11 +211,12 @@ describe('GET /credentials', () => { expect(cred2.scopes).toEqual( [ 'credential:create', - 'credential:read', - 'credential:update', 'credential:delete', 'credential:list', + 'credential:move', + 'credential:read', 'credential:share', + 'credential:update', ].sort(), ); } @@ -576,7 +584,13 @@ describe('POST /credentials', () => { expect(encryptedData).not.toBe(payload.data); expect(scopes).toEqual( - ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + [ + 'credential:delete', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ].sort(), ); const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); @@ -819,11 +833,12 @@ describe('PATCH /credentials/:id', () => { expect(scopes).toEqual( [ 'credential:create', - 'credential:read', - 'credential:update', 'credential:delete', 'credential:list', + 'credential:move', + 'credential:read', 'credential:share', + 'credential:update', ].sort(), ); diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts new file mode 100644 index 0000000000000..f4387f30ad37b --- /dev/null +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -0,0 +1,58 @@ +import type { User } from '@db/entities/User'; + +import * as utils from '../shared/utils/'; +import * as testDb from '../shared/testDb'; +import { createUser } from '../shared/db/users'; +import { createWorkflowWithTrigger } from '../shared/db/workflows'; +import { createTeamProject } from '../shared/db/projects'; +import { mockInstance } from '../../shared/mocking'; +import { WaitTracker } from '@/WaitTracker'; + +let member: User; +let anotherMember: User; + +const testServer = utils.setupTestServer({ + endpointGroups: ['workflows'], + enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'], +}); + +// This is necessary for the tests to shutdown cleanly. +mockInstance(WaitTracker); + +beforeAll(async () => { + member = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: 'global:member' }); + + await utils.initNodeTypes(); +}); + +beforeEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow']); +}); + +describe('PUT /:workflowId/transfer', () => { + // This tests does not mock the ActiveWorkflowManager, which helps catching + // possible deadlocks when using transactions wrong. + test('can transfer an active workflow', async () => { + // + // ARRANGE + // + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflowWithTrigger({ active: true }, member); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response.body).toEqual({}); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 68126824db97b..581d9ecaac803 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -1,6 +1,6 @@ import Container from 'typedi'; import { v4 as uuid } from 'uuid'; -import type { INode } from 'n8n-workflow'; +import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow'; import config from '@/config'; import type { Project } from '@db/entities/Project'; @@ -19,12 +19,15 @@ import type { SaveCredentialFunction } from '../shared/types'; import { makeWorkflow } from '../shared/utils/'; import { randomCredentialPayload } from '../shared/random'; import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; -import { createUser, createUserShell } from '../shared/db/users'; +import { createAdmin, createOwner, createUser, createUserShell } from '../shared/db/users'; import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; let owner: User; +let admin: User; let ownerPersonalProject: Project; let member: User; let memberPersonalProject: Project; @@ -36,21 +39,24 @@ let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; let projectRepository: ProjectRepository; +let workflowRepository: WorkflowRepository; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], - enabledFeatures: ['feat:sharing'], + enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'], }); const license = testServer.license; const mailer = mockInstance(UserManagementMailer); beforeAll(async () => { projectRepository = Container.get(ProjectRepository); + workflowRepository = Container.get(WorkflowRepository); - owner = await createUser({ role: 'global:owner' }); + owner = await createOwner(); + admin = await createAdmin(); ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); member = await createUser({ role: 'global:member' }); memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); @@ -1236,3 +1242,309 @@ describe('PATCH /workflows/:workflowId - activate workflow', () => { expect(active).toBe(false); }); }); + +describe('PUT /:workflowId/transfer', () => { + test('cannot transfer into the same project', async () => { + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({}, destinationProject); + + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(400); + }); + + test('cannot transfer into a personal project', async () => { + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({}, destinationProject); + + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: memberPersonalProject.id }) + .expect(400); + }); + + test('cannot transfer without workflow:move scope for the workflow', async () => { + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({}, anotherMember); + + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(403); + }); + + test('cannot transfer without workflow:create scope in destination project', async () => { + const destinationProject = await createTeamProject('Team Project', anotherMember); + + const workflow = await createWorkflow({}, member); + + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(404); + }); + + test('project:editors cannot transfer workflows', async () => { + // + // ARRANGE + // + const sourceProject = await createTeamProject('Team Project 1'); + await linkUserToProject(member, sourceProject, 'project:editor'); + const destinationProject = await createTeamProject(); + await linkUserToProject(member, destinationProject, 'project:admin'); + + const workflow = await createWorkflow({}, sourceProject); + + // + // ACT & ASSERT + // + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(403); + }); + + test('transferring from a personal project to a team project severs all sharings', async () => { + // + // ARRANGE + // + const workflow = await createWorkflow({}, member); + + // this sharing should be deleted by the transfer + await shareWorkflowWithUsers(workflow, [anotherMember, owner]); + + const destinationProject = await createTeamProject('Team Project', member); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response.body).toEqual({}); + + const allSharings = await getWorkflowSharing(workflow); + expect(allSharings).toHaveLength(1); + expect(allSharings).not.toContainEqual({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + }); + + test('can transfer from team to another team project', async () => { + // + // ARRANGE + // + const sourceProject = await createTeamProject('Team Project 1', member); + const destinationProject = await createTeamProject('Team Project 2', member); + + const workflow = await createWorkflow({}, sourceProject); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response.body).toEqual({}); + + const allSharings = await getWorkflowSharing(workflow); + expect(allSharings).toHaveLength(1); + expect(allSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + }); + + test.each([ + ['owners', () => owner], + ['admins', () => admin], + ])( + 'global %s can always transfer from any personal or team project into any team project', + async (_name, actor) => { + // + // ARRANGE + // + const sourceProject = await createTeamProject('Source Project', member); + const destinationProject = await createTeamProject('Destination Project', member); + + const teamWorkflow = await createWorkflow({}, sourceProject); + const personalWorkflow = await createWorkflow({}, member); + + // + // ACT + // + const response1 = await testServer + .authAgentFor(actor()) + .put(`/workflows/${teamWorkflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + const response2 = await testServer + .authAgentFor(actor()) + .put(`/workflows/${personalWorkflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response1.body).toEqual({}); + expect(response2.body).toEqual({}); + + { + const allSharings = await getWorkflowSharing(teamWorkflow); + expect(allSharings).toHaveLength(1); + expect(allSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: teamWorkflow.id, + role: 'workflow:owner', + }); + } + + { + const allSharings = await getWorkflowSharing(personalWorkflow); + expect(allSharings).toHaveLength(1); + expect(allSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: personalWorkflow.id, + role: 'workflow:owner', + }); + } + }, + ); + + test.each([ + ['owners', () => owner], + ['admins', () => admin], + ])('global %s cannot transfer into personal projects', async (_name, actor) => { + // + // ARRANGE + // + const sourceProject = await createTeamProject('Source Project', member); + const destinationProject = anotherMemberPersonalProject; + + const teamWorkflow = await createWorkflow({}, sourceProject); + const personalWorkflow = await createWorkflow({}, member); + + // + // ACT & ASSERT + // + await testServer + .authAgentFor(actor()) + .put(`/workflows/${teamWorkflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(400); + await testServer + .authAgentFor(actor()) + .put(`/workflows/${personalWorkflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(400); + }); + + test('removes and re-adds the workflow from the active workflow manager during the transfer', async () => { + // + // ARRANGE + // + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({ active: true }, member); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response.body).toEqual({}); + + expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + }); + + test('deactivates the workflow if it cannot be added to the active workflow manager again and returns the WorkflowActivationError as data', async () => { + // + // ARRANGE + // + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({ active: true }, member); + + activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed')); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(200); + + // + // ASSERT + // + expect(response.body).toMatchObject({ + data: { + error: { + message: 'Failed', + name: 'WorkflowActivationError', + }, + }, + }); + + expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + + const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id }); + expect(workflowFromDB).toMatchObject({ active: false }); + }); + + test('returns a 500 if the workflow cannot be activated due to an unknown error', async () => { + // + // ARRANGE + // + const destinationProject = await createTeamProject('Team Project', member); + + const workflow = await createWorkflow({ active: true }, member); + + activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); + + // + // ACT & ASSERT + // + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ destinationProjectId: destinationProject.id }) + .expect(500); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fbfd674d31140..8ada8166ade22 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -116,6 +116,7 @@ describe('POST /workflows', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:move', 'workflow:read', 'workflow:share', 'workflow:update', @@ -519,7 +520,13 @@ describe('GET /workflows', () => { // Team workflow expect(wf1.id).toBe(savedWorkflow1.id); expect(wf1.scopes).toEqual( - ['workflow:read', 'workflow:update', 'workflow:delete', 'workflow:execute'].sort(), + [ + 'workflow:delete', + 'workflow:execute', + 'workflow:move', + 'workflow:read', + 'workflow:update', + ].sort(), ); // Shared workflow @@ -550,11 +557,12 @@ describe('GET /workflows', () => { expect(wf2.id).toBe(savedWorkflow2.id); expect(wf2.scopes).toEqual( [ - 'workflow:read', - 'workflow:update', 'workflow:delete', 'workflow:execute', + 'workflow:move', + 'workflow:read', 'workflow:share', + 'workflow:update', ].sort(), ); } @@ -574,12 +582,13 @@ describe('GET /workflows', () => { expect(wf1.scopes).toEqual( [ 'workflow:create', - 'workflow:read', - 'workflow:update', 'workflow:delete', + 'workflow:execute', 'workflow:list', + 'workflow:move', + 'workflow:read', 'workflow:share', - 'workflow:execute', + 'workflow:update', ].sort(), ); @@ -588,12 +597,13 @@ describe('GET /workflows', () => { expect(wf2.scopes).toEqual( [ 'workflow:create', - 'workflow:read', - 'workflow:update', 'workflow:delete', + 'workflow:execute', 'workflow:list', + 'workflow:move', + 'workflow:read', 'workflow:share', - 'workflow:execute', + 'workflow:update', ].sort(), ); } diff --git a/packages/workflow/src/errors/abstract/execution-base.error.ts b/packages/workflow/src/errors/abstract/execution-base.error.ts index c29e84ef87a7f..819b17353bfbc 100644 --- a/packages/workflow/src/errors/abstract/execution-base.error.ts +++ b/packages/workflow/src/errors/abstract/execution-base.error.ts @@ -37,8 +37,7 @@ export abstract class ExecutionBaseError extends ApplicationError { if (errorResponse) this.errorResponse = errorResponse; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toJSON?(): any { + toJSON?() { return { message: this.message, lineNumber: this.lineNumber,