mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Allow transferring workflows from any project to any team project (#9534)
This commit is contained in:
parent
68420ca6be
commit
d6db8cbf23
|
@ -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
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
import { ResponseError } from './abstract/response.error';
|
||||
|
||||
export class NotFoundError extends ResponseError {
|
||||
static isDefinedAndNotNull<T>(
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { ResponseError } from './abstract/response.error';
|
||||
|
||||
export class TransferWorkflowError extends ResponseError {
|
||||
constructor(message: string) {
|
||||
super(message, 400, 400);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -142,7 +142,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
|
||||
|
@ -169,7 +169,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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -188,11 +194,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(),
|
||||
);
|
||||
|
||||
|
@ -201,11 +208,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(),
|
||||
);
|
||||
}
|
||||
|
@ -573,7 +581,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 });
|
||||
|
@ -816,11 +830,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(),
|
||||
);
|
||||
|
||||
|
|
|
@ -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({});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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:list',
|
||||
'workflow:share',
|
||||
'workflow:execute',
|
||||
'workflow:list',
|
||||
'workflow:move',
|
||||
'workflow:read',
|
||||
'workflow:share',
|
||||
'workflow:update',
|
||||
].sort(),
|
||||
);
|
||||
|
||||
|
@ -588,12 +597,13 @@ describe('GET /workflows', () => {
|
|||
expect(wf2.scopes).toEqual(
|
||||
[
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
'workflow:update',
|
||||
'workflow:delete',
|
||||
'workflow:list',
|
||||
'workflow:share',
|
||||
'workflow:execute',
|
||||
'workflow:list',
|
||||
'workflow:move',
|
||||
'workflow:read',
|
||||
'workflow:share',
|
||||
'workflow:update',
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue