mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): reimplement blocking workflow updates on interim changes (#4446)
* 📘 Update request type * 📘 Update FE types * ⚡ Adjust store * ⚡ Set received hash * ⚡ Send and load hash * ⚡ Make helper more flexible * 🗃️ Add new field to entity * 🚨 Add check to endpoint * 🧪 Add tests * ⚡ Add `forceSave` flag * 🐛 Fix workflow update failing on new workflow * 🧪 Add more tests * ⚡ Move check to `updateWorkflow()` * ⚡ Refactor to accommodate latest changes * 🧪 Refactor tests to keep them passing * ⚡ Improve syntax
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
import { Length } from 'class-validator';
|
import { Length } from 'class-validator';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -10,6 +11,9 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AfterLoad,
|
||||||
|
AfterUpdate,
|
||||||
|
AfterInsert,
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
@@ -84,6 +88,30 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {
|
|||||||
transformer: sqlite.jsonColumn,
|
transformer: sqlite.jsonColumn,
|
||||||
})
|
})
|
||||||
pinData: ISimplifiedPinData;
|
pinData: ISimplifiedPinData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash of editable workflow state.
|
||||||
|
*/
|
||||||
|
hash: string;
|
||||||
|
|
||||||
|
@AfterLoad()
|
||||||
|
@AfterUpdate()
|
||||||
|
@AfterInsert()
|
||||||
|
setHash(): void {
|
||||||
|
const { name, active, nodes, connections, settings, staticData, pinData } = this;
|
||||||
|
|
||||||
|
const state = JSON.stringify({
|
||||||
|
name,
|
||||||
|
active,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
settings,
|
||||||
|
staticData,
|
||||||
|
pinData,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hash = crypto.createHash('md5').update(state).digest('hex');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
3
packages/cli/src/requests.d.ts
vendored
3
packages/cli/src/requests.d.ts
vendored
@@ -48,6 +48,7 @@ export declare namespace WorkflowRequest {
|
|||||||
settings: IWorkflowSettings;
|
settings: IWorkflowSettings;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
hash: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type Create = AuthenticatedRequest<{}, {}, RequestBody>;
|
type Create = AuthenticatedRequest<{}, {}, RequestBody>;
|
||||||
@@ -56,7 +57,7 @@ export declare namespace WorkflowRequest {
|
|||||||
|
|
||||||
type Delete = Get;
|
type Delete = Get;
|
||||||
|
|
||||||
type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>;
|
type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody, { forceSave?: string }>;
|
||||||
|
|
||||||
type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
|
type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ EEWorkflowController.patch(
|
|||||||
'/:id(\\d+)',
|
'/:id(\\d+)',
|
||||||
ResponseHelper.send(async (req: WorkflowRequest.Update) => {
|
ResponseHelper.send(async (req: WorkflowRequest.Update) => {
|
||||||
const { id: workflowId } = req.params;
|
const { id: workflowId } = req.params;
|
||||||
|
const forceSave = req.query.forceSave === 'true';
|
||||||
|
|
||||||
const updateData = new WorkflowEntity();
|
const updateData = new WorkflowEntity();
|
||||||
const { tags, ...rest } = req.body;
|
const { tags, ...rest } = req.body;
|
||||||
@@ -193,6 +194,7 @@ EEWorkflowController.patch(
|
|||||||
updateData,
|
updateData,
|
||||||
workflowId,
|
workflowId,
|
||||||
tags,
|
tags,
|
||||||
|
forceSave,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { id, ...remainder } = updatedWorkflow;
|
const { id, ...remainder } = updatedWorkflow;
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export class EEWorkflowsService extends WorkflowsService {
|
|||||||
workflow: WorkflowEntity,
|
workflow: WorkflowEntity,
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
tags?: string[],
|
tags?: string[],
|
||||||
|
forceSave?: boolean,
|
||||||
): Promise<WorkflowEntity> {
|
): Promise<WorkflowEntity> {
|
||||||
const previousVersion = await EEWorkflowsService.get({ id: parseInt(workflowId, 10) });
|
const previousVersion = await EEWorkflowsService.get({ id: parseInt(workflowId, 10) });
|
||||||
if (!previousVersion) {
|
if (!previousVersion) {
|
||||||
@@ -128,13 +129,13 @@ export class EEWorkflowsService extends WorkflowsService {
|
|||||||
}
|
}
|
||||||
const allCredentials = await EECredentials.getAll(user);
|
const allCredentials = await EECredentials.getAll(user);
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||||
workflow = WorkflowHelpers.validateWorkflowCredentialUsage(
|
workflow = WorkflowHelpers.validateWorkflowCredentialUsage(
|
||||||
workflow,
|
workflow,
|
||||||
previousVersion,
|
previousVersion,
|
||||||
allCredentials,
|
allCredentials,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
|
||||||
throw new ResponseHelper.ResponseError(
|
throw new ResponseHelper.ResponseError(
|
||||||
'Invalid workflow credentials - make sure you have access to all credentials and try again.',
|
'Invalid workflow credentials - make sure you have access to all credentials and try again.',
|
||||||
undefined,
|
undefined,
|
||||||
@@ -142,6 +143,6 @@ export class EEWorkflowsService extends WorkflowsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.updateWorkflow(user, workflow, workflowId, tags);
|
return super.updateWorkflow(user, workflow, workflowId, tags, forceSave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export class WorkflowsService {
|
|||||||
workflow: WorkflowEntity,
|
workflow: WorkflowEntity,
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
tags?: string[],
|
tags?: string[],
|
||||||
|
forceSave?: boolean,
|
||||||
): Promise<WorkflowEntity> {
|
): Promise<WorkflowEntity> {
|
||||||
const shared = await Db.collections.SharedWorkflow.findOne({
|
const shared = await Db.collections.SharedWorkflow.findOne({
|
||||||
relations: ['workflow'],
|
relations: ['workflow'],
|
||||||
@@ -74,6 +75,14 @@ export class WorkflowsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!forceSave && workflow.hash !== shared.workflow.hash) {
|
||||||
|
throw new ResponseHelper.ResponseError(
|
||||||
|
`Workflow ID ${workflowId} cannot be saved because it was changed by another user.`,
|
||||||
|
undefined,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// check credentials for old format
|
// check credentials for old format
|
||||||
await WorkflowHelpers.replaceInvalidCredentials(workflow);
|
await WorkflowHelpers.replaceInvalidCredentials(workflow);
|
||||||
|
|
||||||
@@ -118,7 +127,9 @@ export class WorkflowsService {
|
|||||||
await validateEntity(workflow);
|
await validateEntity(workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Db.collections.Workflow.update(workflowId, workflow);
|
const { hash, ...rest } = workflow;
|
||||||
|
|
||||||
|
await Db.collections.Workflow.update(workflowId, rest);
|
||||||
|
|
||||||
if (tags && !config.getEnv('workflowTagsDisabled')) {
|
if (tags && !config.getEnv('workflowTagsDisabled')) {
|
||||||
const tablePrefix = config.getEnv('database.tablePrefix');
|
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||||
|
|||||||
@@ -706,10 +706,7 @@ export const emptyPackage = () => {
|
|||||||
// workflow
|
// workflow
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export function makeWorkflow({
|
export function makeWorkflow(options?: {
|
||||||
withPinData,
|
|
||||||
withCredential,
|
|
||||||
}: {
|
|
||||||
withPinData: boolean;
|
withPinData: boolean;
|
||||||
withCredential?: { id: string; name: string };
|
withCredential?: { id: string; name: string };
|
||||||
}) {
|
}) {
|
||||||
@@ -717,16 +714,16 @@ export function makeWorkflow({
|
|||||||
|
|
||||||
const node: INode = {
|
const node: INode = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: 'Spotify',
|
name: 'Cron',
|
||||||
type: 'n8n-nodes-base.spotify',
|
type: 'n8n-nodes-base.cron',
|
||||||
parameters: { resource: 'track', operation: 'get', id: '123' },
|
parameters: {},
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
position: [740, 240],
|
position: [740, 240],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (withCredential) {
|
if (options?.withCredential) {
|
||||||
node.credentials = {
|
node.credentials = {
|
||||||
spotifyApi: withCredential,
|
spotifyApi: options.withCredential,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +732,7 @@ export function makeWorkflow({
|
|||||||
workflow.connections = {};
|
workflow.connections = {};
|
||||||
workflow.nodes = [node];
|
workflow.nodes = [node];
|
||||||
|
|
||||||
if (withPinData) {
|
if (options?.withPinData) {
|
||||||
workflow.pinData = MOCK_PINDATA;
|
workflow.pinData = MOCK_PINDATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import config from '../../config';
|
|||||||
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
||||||
import { makeWorkflow } from './shared/utils';
|
import { makeWorkflow } from './shared/utils';
|
||||||
import { randomCredentialPayload } from './shared/random';
|
import { randomCredentialPayload } from './shared/random';
|
||||||
import { INode, INodes } from 'n8n-workflow';
|
import { ActiveWorkflowRunner } from '../../src';
|
||||||
|
import { INode } from 'n8n-workflow';
|
||||||
|
|
||||||
jest.mock('../../src/telemetry');
|
jest.mock('../../src/telemetry');
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ let globalMemberRole: Role;
|
|||||||
let credentialOwnerRole: Role;
|
let credentialOwnerRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
let saveCredential: SaveCredentialFunction;
|
let saveCredential: SaveCredentialFunction;
|
||||||
|
let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({
|
||||||
@@ -47,6 +49,9 @@ beforeAll(async () => {
|
|||||||
utils.initTestTelemetry();
|
utils.initTestTelemetry();
|
||||||
|
|
||||||
config.set('enterprise.workflowSharingEnabled', true);
|
config.set('enterprise.workflowSharingEnabled', true);
|
||||||
|
|
||||||
|
await utils.initNodeTypes();
|
||||||
|
workflowRunner = await utils.initActiveWorkflowRunner();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -287,13 +292,15 @@ describe('POST /workflows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /workflows/:id', () => {
|
describe('PATCH /workflows/:id - validate credential permissions to user', () => {
|
||||||
it('Should succeed when saving unchanged workflow nodes', async () => {
|
it('Should succeed when saving unchanged workflow nodes', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
const workflow = await createWorkflow(
|
const workflow = {
|
||||||
{
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
connections: {},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: 'uuid-1234',
|
id: 'uuid-1234',
|
||||||
@@ -310,12 +317,14 @@ describe('PATCH /workflows/:id', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
owner,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await authAgent(owner).patch(`/workflows/${workflow.id}`).send({
|
const createResponse = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
const { id, hash } = createResponse.body.data;
|
||||||
|
|
||||||
|
const response = await authAgent(owner).patch(`/workflows/${id}`).send({
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
|
hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
@@ -326,11 +335,35 @@ describe('PATCH /workflows/:id', () => {
|
|||||||
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = {
|
||||||
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
connections: {},
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
const { id, hash } = createResponse.body.data;
|
||||||
|
|
||||||
const response = await authAgent(owner)
|
const response = await authAgent(owner)
|
||||||
.patch(`/workflows/${workflow.id}`)
|
.patch(`/workflows/${id}`)
|
||||||
.send({
|
.send({
|
||||||
|
hash,
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: 'uuid-1234',
|
id: 'uuid-1234',
|
||||||
@@ -357,11 +390,36 @@ describe('PATCH /workflows/:id', () => {
|
|||||||
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
const workflow = await createWorkflow({}, member);
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
connections: {},
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
const { id, hash } = createResponse.body.data;
|
||||||
|
|
||||||
const response = await authAgent(member)
|
const response = await authAgent(member)
|
||||||
.patch(`/workflows/${workflow.id}`)
|
.patch(`/workflows/${id}`)
|
||||||
.send({
|
.send({
|
||||||
|
hash,
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: 'uuid-1234',
|
id: 'uuid-1234',
|
||||||
@@ -432,10 +490,22 @@ describe('PATCH /workflows/:id', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const workflow = await createWorkflow({ nodes: originalNodes }, member1);
|
const workflow = {
|
||||||
await testDb.shareWorkflowWithUsers(workflow, [member2]);
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
connections: {},
|
||||||
|
nodes: originalNodes,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await authAgent(member2).patch(`/workflows/${workflow.id}`).send({
|
const createResponse = await authAgent(member1).post('/workflows').send(workflow);
|
||||||
|
const { id, hash } = createResponse.body.data;
|
||||||
|
|
||||||
|
await authAgent(member1)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member2.id] });
|
||||||
|
|
||||||
|
const response = await authAgent(member2).patch(`/workflows/${id}`).send({
|
||||||
|
hash,
|
||||||
nodes: changedNodes,
|
nodes: changedNodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,3 +513,219 @@ describe('PATCH /workflows/:id', () => {
|
|||||||
expect(response.body.data.nodes).toMatchObject(originalNodes);
|
expect(response.body.data.nodes).toMatchObject(originalNodes);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PATCH /workflows/:id - validate interim updates', () => {
|
||||||
|
it('should block owner updating workflow nodes on interim update by member', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerHash } = createResponse.body.data;
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses and updates workflow name
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
|
||||||
|
await authAgent(member)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ name: 'Update by member', hash: memberHash });
|
||||||
|
|
||||||
|
// owner blocked from updating workflow nodes
|
||||||
|
|
||||||
|
const updateAttemptResponse = await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ nodes: [], hash: ownerHash });
|
||||||
|
|
||||||
|
expect(updateAttemptResponse.status).toBe(400);
|
||||||
|
expect(updateAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block member updating workflow nodes on interim update by owner', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates, updates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerFirstHash } = createResponse.body.data;
|
||||||
|
|
||||||
|
const updateResponse = await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ name: 'Update by owner', hash: ownerFirstHash });
|
||||||
|
|
||||||
|
const { hash: ownerSecondHash } = updateResponse.body.data;
|
||||||
|
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses workflow
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
|
||||||
|
// owner re-updates workflow
|
||||||
|
|
||||||
|
await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ name: 'Owner update again', hash: ownerSecondHash });
|
||||||
|
|
||||||
|
// member blocked from updating workflow
|
||||||
|
|
||||||
|
const updateAttemptResponse = await authAgent(member)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ nodes: [], hash: memberHash });
|
||||||
|
|
||||||
|
expect(updateAttemptResponse.status).toBe(400);
|
||||||
|
expect(updateAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block owner activation on interim activation by member', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerHash } = createResponse.body.data;
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses and activates workflow
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
await authAgent(member).patch(`/workflows/${id}`).send({ active: true, hash: memberHash });
|
||||||
|
|
||||||
|
// owner blocked from activating workflow
|
||||||
|
|
||||||
|
const activationAttemptResponse = await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ active: true, hash: ownerHash });
|
||||||
|
|
||||||
|
expect(activationAttemptResponse.status).toBe(400);
|
||||||
|
expect(activationAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block member activation on interim activation by owner', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates, updates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerFirstHash } = createResponse.body.data;
|
||||||
|
|
||||||
|
const updateResponse = await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ name: 'Update by owner', hash: ownerFirstHash });
|
||||||
|
const { hash: ownerSecondHash } = updateResponse.body.data;
|
||||||
|
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses workflow
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
|
||||||
|
// owner activates workflow
|
||||||
|
|
||||||
|
await authAgent(owner).patch(`/workflows/${id}`).send({ active: true, hash: ownerSecondHash });
|
||||||
|
|
||||||
|
// member blocked from activating workflow
|
||||||
|
|
||||||
|
const updateAttemptResponse = await authAgent(member)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ active: true, hash: memberHash });
|
||||||
|
|
||||||
|
expect(updateAttemptResponse.status).toBe(400);
|
||||||
|
expect(updateAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block member updating workflow settings on interim update by owner', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerHash } = createResponse.body.data;
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses workflow
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
|
||||||
|
// owner updates workflow name
|
||||||
|
|
||||||
|
await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ name: 'Another name', hash: ownerHash });
|
||||||
|
|
||||||
|
// member blocked from updating workflow settings
|
||||||
|
|
||||||
|
const updateAttemptResponse = await authAgent(member)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ settings: { saveManualExecutions: true }, hash: memberHash });
|
||||||
|
|
||||||
|
expect(updateAttemptResponse.status).toBe(400);
|
||||||
|
expect(updateAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block member updating workflow name on interim update by owner', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// owner creates and shares workflow
|
||||||
|
|
||||||
|
const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow());
|
||||||
|
const { id, hash: ownerHash } = createResponse.body.data;
|
||||||
|
await authAgent(owner)
|
||||||
|
.put(`/workflows/${id}/share`)
|
||||||
|
.send({ shareWithIds: [member.id] });
|
||||||
|
|
||||||
|
// member accesses workflow
|
||||||
|
|
||||||
|
const memberGetResponse = await authAgent(member).get(`/workflows/${id}`);
|
||||||
|
const { hash: memberHash } = memberGetResponse.body.data;
|
||||||
|
|
||||||
|
// owner updates workflow settings
|
||||||
|
|
||||||
|
await authAgent(owner)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ settings: { saveManualExecutions: true }, hash: ownerHash });
|
||||||
|
|
||||||
|
// member blocked from updating workflow name
|
||||||
|
|
||||||
|
const updateAttemptResponse = await authAgent(member)
|
||||||
|
.patch(`/workflows/${id}`)
|
||||||
|
.send({ settings: { saveManualExecutions: true }, hash: memberHash });
|
||||||
|
|
||||||
|
expect(updateAttemptResponse.status).toBe(400);
|
||||||
|
expect(updateAttemptResponse.body.message).toContain(
|
||||||
|
'cannot be saved because it was changed by another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export interface IWorkflowData {
|
|||||||
settings?: IWorkflowSettings;
|
settings?: IWorkflowSettings;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
pinData?: IPinData;
|
pinData?: IPinData;
|
||||||
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkflowDataUpdate {
|
export interface IWorkflowDataUpdate {
|
||||||
@@ -279,6 +280,7 @@ export interface IWorkflowDataUpdate {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
|
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
|
||||||
pinData?: IPinData;
|
pinData?: IPinData;
|
||||||
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
||||||
@@ -315,6 +317,7 @@ export interface IWorkflowDb {
|
|||||||
pinData?: IPinData;
|
pinData?: IPinData;
|
||||||
sharedWith?: Array<Partial<IUser>>;
|
sharedWith?: Array<Partial<IUser>>;
|
||||||
ownedBy?: Partial<IUser>;
|
ownedBy?: Partial<IUser>;
|
||||||
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identical to cli.Interfaces.ts
|
// Identical to cli.Interfaces.ts
|
||||||
|
|||||||
@@ -589,9 +589,11 @@ export default mixins(
|
|||||||
delete data.settings!.maxExecutionTimeout;
|
delete data.settings!.maxExecutionTimeout;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
data.hash = this.$store.getters.workflowHash;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.restApi().updateWorkflow(this.workflowId, data);
|
const workflow = await this.restApi().updateWorkflow(this.$route.params.name, data);
|
||||||
|
this.$store.commit('setWorkflowHash', workflow.hash);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$showError(
|
this.$showError(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ export const workflowHelpers = mixins(
|
|||||||
active: this.$store.getters.isActive,
|
active: this.$store.getters.isActive,
|
||||||
settings: this.$store.getters.workflowSettings,
|
settings: this.$store.getters.workflowSettings,
|
||||||
tags: this.$store.getters.workflowTags,
|
tags: this.$store.getters.workflowTags,
|
||||||
|
hash: this.$store.getters.workflowHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
const workflowId = this.$store.getters.workflowId;
|
const workflowId = this.$store.getters.workflowId;
|
||||||
@@ -660,6 +661,9 @@ export const workflowHelpers = mixins(
|
|||||||
const isCurrentWorkflow = workflowId === this.$store.getters.workflowId;
|
const isCurrentWorkflow = workflowId === this.$store.getters.workflowId;
|
||||||
if (isCurrentWorkflow) {
|
if (isCurrentWorkflow) {
|
||||||
data = await this.getWorkflowDataToSave();
|
data = await this.getWorkflowDataToSave();
|
||||||
|
} else {
|
||||||
|
const { hash } = await this.restApi().getWorkflow(workflowId);
|
||||||
|
data.hash = hash as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active !== undefined) {
|
if (active !== undefined) {
|
||||||
@@ -667,6 +671,7 @@ export const workflowHelpers = mixins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workflow = await this.restApi().updateWorkflow(workflowId, data);
|
const workflow = await this.restApi().updateWorkflow(workflowId, data);
|
||||||
|
this.$store.commit('setWorkflowHash', workflow.hash);
|
||||||
|
|
||||||
if (isCurrentWorkflow) {
|
if (isCurrentWorkflow) {
|
||||||
this.$store.commit('setActive', !!workflow.active);
|
this.$store.commit('setActive', !!workflow.active);
|
||||||
@@ -701,7 +706,10 @@ export const workflowHelpers = mixins(
|
|||||||
workflowDataRequest.tags = tags;
|
workflowDataRequest.tags = tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workflowDataRequest.hash = this.$store.getters.workflowHash;
|
||||||
|
|
||||||
const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
|
const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
|
||||||
|
this.$store.commit('setWorkflowHash', workflowData.hash);
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
this.$store.commit('setWorkflowName', {newName: workflowData.name});
|
this.$store.commit('setWorkflowName', {newName: workflowData.name});
|
||||||
@@ -768,6 +776,7 @@ export const workflowHelpers = mixins(
|
|||||||
const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest);
|
const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest);
|
||||||
|
|
||||||
this.$store.commit('addWorkflow', workflowData);
|
this.$store.commit('addWorkflow', workflowData);
|
||||||
|
this.$store.commit('setWorkflowHash', workflowData.hash);
|
||||||
|
|
||||||
if (openInNewWindow) {
|
if (openInNewWindow) {
|
||||||
const routeData = this.$router.resolve({name: VIEWS.WORKFLOW, params: {name: workflowData.id}});
|
const routeData = this.$router.resolve({name: VIEWS.WORKFLOW, params: {name: workflowData.id}});
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const state: IRootState = {
|
|||||||
settings: {},
|
settings: {},
|
||||||
tags: [],
|
tags: [],
|
||||||
pinData: {},
|
pinData: {},
|
||||||
|
hash: '',
|
||||||
},
|
},
|
||||||
workflowsById: {},
|
workflowsById: {},
|
||||||
sidebarMenuItems: [],
|
sidebarMenuItems: [],
|
||||||
@@ -473,6 +474,10 @@ export const store = new Vuex.Store({
|
|||||||
state.workflow.name = data.newName;
|
state.workflow.name = data.newName;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWorkflowHash(state, hash: string) {
|
||||||
|
state.workflow.hash = hash;
|
||||||
|
},
|
||||||
|
|
||||||
// replace invalid credentials in workflow
|
// replace invalid credentials in workflow
|
||||||
replaceInvalidWorkflowCredentials(state, {credentials, invalid, type}) {
|
replaceInvalidWorkflowCredentials(state, {credentials, invalid, type}) {
|
||||||
state.workflow.nodes.forEach((node) => {
|
state.workflow.nodes.forEach((node) => {
|
||||||
@@ -761,6 +766,9 @@ export const store = new Vuex.Store({
|
|||||||
subworkflowExecutionError: (state): Error | null => {
|
subworkflowExecutionError: (state): Error | null => {
|
||||||
return state.subworkflowExecutionError;
|
return state.subworkflowExecutionError;
|
||||||
},
|
},
|
||||||
|
workflowHash: (state): string | undefined => {
|
||||||
|
return state.workflow.hash;
|
||||||
|
},
|
||||||
|
|
||||||
isActionActive: (state) => (action: string): boolean => {
|
isActionActive: (state) => (action: string): boolean => {
|
||||||
return state.activeActions.includes(action);
|
return state.activeActions.includes(action);
|
||||||
|
|||||||
@@ -793,6 +793,8 @@ export default mixins(
|
|||||||
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
|
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
|
||||||
this.$store.commit('setWorkflowSettings', data.settings || {});
|
this.$store.commit('setWorkflowSettings', data.settings || {});
|
||||||
this.$store.commit('setWorkflowPinData', data.pinData || {});
|
this.$store.commit('setWorkflowPinData', data.pinData || {});
|
||||||
|
this.$store.commit('setWorkflowHash', data.hash);
|
||||||
|
|
||||||
const tags = (data.tags || []) as ITag[];
|
const tags = (data.tags || []) as ITag[];
|
||||||
this.$store.commit('tags/upsertTags', tags);
|
this.$store.commit('tags/upsertTags', tags);
|
||||||
const tagIds = tags.map((tag) => tag.id);
|
const tagIds = tags.map((tag) => tag.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user