From c65deb29491fab22575bc2376c8d546916c5694f Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 26 Oct 2022 10:49:43 -0300 Subject: [PATCH] feat: check for cred when updating workflow and remove credential_usage table (#4350) (no-changelog) * feat: check for cred when updating workflow and remove credential_usage table --- packages/cli/src/Db.ts | 1 - packages/cli/src/Interfaces.ts | 2 - packages/cli/src/WorkflowHelpers.ts | 81 +++++++ .../src/databases/entities/CredentialUsage.ts | 28 --- packages/cli/src/databases/entities/index.ts | 2 - ...665484192213-CreateCredentialUsageTable.ts | 4 +- ...665754637026-RemoveCredentialUsageTable.ts | 37 +++ .../src/databases/migrations/mysqldb/index.ts | 2 + ...665754637025-RemoveCredentialUsageTable.ts | 30 +++ .../databases/migrations/postgresdb/index.ts | 2 + ...665754637024-RemoveCredentialUsageTable.ts | 30 +++ .../src/databases/migrations/sqlite/index.ts | 2 + .../src/workflows/workflows.controller.ee.ts | 49 ++-- .../cli/src/workflows/workflows.controller.ts | 129 +--------- .../src/workflows/workflows.services.ee.ts | 32 ++- .../cli/src/workflows/workflows.services.ts | 151 +++++++++++- .../cli/test/integration/shared/testDb.ts | 13 - .../workflows.controller.ee.test.ts | 172 +++++++++++++- .../cli/test/unit/WorkflowHelpers.test.ts | 224 ++++++++++++++++++ 19 files changed, 781 insertions(+), 210 deletions(-) delete mode 100644 packages/cli/src/databases/entities/CredentialUsage.ts create mode 100644 packages/cli/src/databases/migrations/mysqldb/1665754637026-RemoveCredentialUsageTable.ts create mode 100644 packages/cli/src/databases/migrations/postgresdb/1665754637025-RemoveCredentialUsageTable.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1665754637024-RemoveCredentialUsageTable.ts create mode 100644 packages/cli/test/unit/WorkflowHelpers.test.ts diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 0d80467cfa..a58c543869 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -202,7 +202,6 @@ export async function init( collections.Settings = linkRepository(entities.Settings); collections.InstalledPackages = linkRepository(entities.InstalledPackages); collections.InstalledNodes = linkRepository(entities.InstalledNodes); - collections.CredentialUsage = linkRepository(entities.CredentialUsage); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 3b092f2055..8bb4393cd6 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -40,7 +40,6 @@ import type { SharedWorkflow } from './databases/entities/SharedWorkflow'; import type { TagEntity } from './databases/entities/TagEntity'; import type { User } from './databases/entities/User'; import type { WorkflowEntity } from './databases/entities/WorkflowEntity'; -import { CredentialUsage } from './databases/entities/CredentialUsage'; export interface IActivationError { time: number; @@ -84,7 +83,6 @@ export interface IDatabaseCollections { Settings: Repository; InstalledPackages: Repository; InstalledNodes: Repository; - CredentialUsage: Repository; } export interface IWebhookDb { diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index f5a5692d6d..37bc881d05 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -29,6 +29,7 @@ import { v4 as uuid } from 'uuid'; import { CredentialTypes, Db, + ICredentialsDb, ICredentialsTypeData, ITransferNodeTypes, IWorkflowErrorData, @@ -704,3 +705,83 @@ export function generateFailedExecutionFromError( stoppedAt: new Date(), }; } + +/** Get all nodes in a workflow where the node credential is not accessible to the user. */ +export function getNodesWithInaccessibleCreds(workflow: WorkflowEntity, userCredIds: string[]) { + if (!workflow.nodes) { + return []; + } + return workflow.nodes.filter((node) => { + if (!node.credentials) return false; + + const allUsedCredentials = Object.values(node.credentials); + + const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString()); + return allUsedCredentialIds.some( + (nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId), + ); + }); +} + +export function validateWorkflowCredentialUsage( + newWorkflowVersion: WorkflowEntity, + previousWorkflowVersion: WorkflowEntity, + credentialsUserHasAccessTo: ICredentialsDb[], +) { + /** + * We only need to check nodes that use credentials the current user cannot access, + * since these can be 2 possibilities: + * - Same ID already exist: it's a read only node and therefore cannot be changed + * - It's a new node which indicates tampering and therefore must fail saving + */ + + const allowedCredentialIds = credentialsUserHasAccessTo.map((cred) => cred.id.toString()); + + const nodesWithCredentialsUserDoesNotHaveAccessTo = getNodesWithInaccessibleCreds( + newWorkflowVersion, + allowedCredentialIds, + ); + + // If there are no nodes with credentials the user does not have access to we can skip the rest + if (nodesWithCredentialsUserDoesNotHaveAccessTo.length === 0) { + return newWorkflowVersion; + } + + const previouslyExistingNodeIds = previousWorkflowVersion.nodes.map((node) => node.id); + + // If it's a new node we can't allow it to be saved + // since it uses creds the node doesn't have access + const isTamperingAttempt = (inaccessibleCredNodeId: string) => + !previouslyExistingNodeIds.includes(inaccessibleCredNodeId); + + nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => { + if (isTamperingAttempt(node.id)) { + Logger.info('Blocked workflow update due to tampering attempt', { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id, + nodeCredentials: node.credentials, + }); + // Node is new, so this is probably a tampering attempt. Throw an error + throw new Error( + 'Workflow contains new nodes with credentials the user does not have access to', + ); + } + // Replace the node with the previous version of the node + // Since it cannot be modified (read only node) + const nodeIdx = newWorkflowVersion.nodes.findIndex( + (newWorkflowNode) => newWorkflowNode.id === node.id, + ); + + Logger.debug('Replacing node with previous version when saving updated workflow', { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id, + }); + newWorkflowVersion.nodes[nodeIdx] = previousWorkflowVersion.nodes.find( + (previousNode) => previousNode.id === node.id, + )!; + }); + + return newWorkflowVersion; +} diff --git a/packages/cli/src/databases/entities/CredentialUsage.ts b/packages/cli/src/databases/entities/CredentialUsage.ts deleted file mode 100644 index bf4a93317d..0000000000 --- a/packages/cli/src/databases/entities/CredentialUsage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Entity, ManyToOne, PrimaryColumn, RelationId } from 'typeorm'; -import { WorkflowEntity } from './WorkflowEntity'; -import { CredentialsEntity } from './CredentialsEntity'; -import { AbstractEntity } from './AbstractEntity'; - -@Entity() -export class CredentialUsage extends AbstractEntity { - @ManyToOne(() => WorkflowEntity, { - onDelete: 'CASCADE', - }) - workflow: WorkflowEntity; - - @ManyToOne(() => CredentialsEntity, { - onDelete: 'CASCADE', - }) - credential: CredentialsEntity; - - @RelationId((credentialUsage: CredentialUsage) => credentialUsage.workflow) - @PrimaryColumn() - workflowId: number; - - @PrimaryColumn() - nodeId: string; - - @RelationId((credentialUsage: CredentialUsage) => credentialUsage.credential) - @PrimaryColumn() - credentialId: string; -} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index b22928714f..4c7358f7e7 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -11,7 +11,6 @@ import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; import { InstalledPackages } from './InstalledPackages'; import { InstalledNodes } from './InstalledNodes'; -import { CredentialUsage } from './CredentialUsage'; export const entities = { CredentialsEntity, @@ -26,5 +25,4 @@ export const entities = { SharedCredentials, InstalledPackages, InstalledNodes, - CredentialUsage, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts b/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts index 741e74b6c0..9d92843529 100644 --- a/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts +++ b/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts @@ -33,8 +33,6 @@ export class CreateCredentialUsageTable1665484192213 implements MigrationInterfa async down(queryRunner: QueryRunner) { const tablePrefix = getTablePrefix(); - await queryRunner.query(` - DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow'; - `); + await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`); } } diff --git a/packages/cli/src/databases/migrations/mysqldb/1665754637026-RemoveCredentialUsageTable.ts b/packages/cli/src/databases/migrations/mysqldb/1665754637026-RemoveCredentialUsageTable.ts new file mode 100644 index 0000000000..339eefdd22 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1665754637026-RemoveCredentialUsageTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; + +export class RemoveCredentialUsageTable1665754637026 implements MigrationInterface { + name = 'RemoveCredentialUsageTable1665754637026'; + + async up(queryRunner: QueryRunner) { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE \`${tablePrefix}credential_usage\``); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner) { + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE \`${tablePrefix}credential_usage\` (` + + '`workflowId` int NOT NULL,' + + '`nodeId` char(200) NOT NULL,' + + "`credentialId` int NOT NULL DEFAULT '1'," + + `\`createdAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,` + + `\`updatedAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,` + + 'PRIMARY KEY (`workflowId`, `nodeId`, `credentialId`)' + + ") ENGINE='InnoDB';", + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}credential_usage\` ADD CONSTRAINT \`FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23\` FOREIGN KEY (\`workflowId\`) REFERENCES \`${tablePrefix}workflow_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}credential_usage\` ADD CONSTRAINT \`FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f\` FOREIGN KEY (\`credentialId\`) REFERENCES \`${tablePrefix}credentials_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 3668d95956..09fd2e57ea 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -22,6 +22,7 @@ import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinDat import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole'; import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable'; +import { RemoveCredentialUsageTable1665754637026 } from './1665754637026-RemoveCredentialUsageTable'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -48,4 +49,5 @@ export const mysqlMigrations = [ CreateCredentialsUserRole1660062385367, CreateWorkflowsEditorRole1663755770894, CreateCredentialUsageTable1665484192213, + RemoveCredentialUsageTable1665754637026, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1665754637025-RemoveCredentialUsageTable.ts b/packages/cli/src/databases/migrations/postgresdb/1665754637025-RemoveCredentialUsageTable.ts new file mode 100644 index 0000000000..4bbc76e357 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1665754637025-RemoveCredentialUsageTable.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; + +export class RemoveCredentialUsageTable1665754637025 implements MigrationInterface { + name = 'RemoveCredentialUsageTable1665754637025'; + + async up(queryRunner: QueryRunner) { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE ${tablePrefix}credential_usage`); + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner) { + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}credential_usage (` + + '"workflowId" int NOT NULL,' + + '"nodeId" UUID NOT NULL,' + + '"credentialId" int NULL,' + + '"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + '"updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + `CONSTRAINT "PK_${tablePrefix}feb7a6545aa714ac6e7f6b14825f0efc9353dd3a" PRIMARY KEY ("workflowId", "nodeId", "credentialId"), ` + + `CONSTRAINT "FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23" FOREIGN KEY ("workflowId") REFERENCES ${tablePrefix}workflow_entity ("id") ON DELETE CASCADE ON UPDATE CASCADE, ` + + `CONSTRAINT "FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f" FOREIGN KEY ("credentialId") REFERENCES ${tablePrefix}credentials_entity ("id") ON DELETE CASCADE ON UPDATE CASCADE ` + + ');', + ); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index ba386056bf..dfee7504f9 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -20,6 +20,7 @@ import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinDat import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole'; import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable'; +import { RemoveCredentialUsageTable1665754637025 } from './1665754637025-RemoveCredentialUsageTable'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -44,4 +45,5 @@ export const postgresMigrations = [ AddJsonKeyPinData1659902242948, CreateWorkflowsEditorRole1663755770893, CreateCredentialUsageTable1665484192212, + RemoveCredentialUsageTable1665754637025, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1665754637024-RemoveCredentialUsageTable.ts b/packages/cli/src/databases/migrations/sqlite/1665754637024-RemoveCredentialUsageTable.ts new file mode 100644 index 0000000000..61d898cf30 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1665754637024-RemoveCredentialUsageTable.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; + +export class RemoveCredentialUsageTable1665754637024 implements MigrationInterface { + name = 'RemoveCredentialUsageTable1665754637024'; + + async up(queryRunner: QueryRunner) { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`); + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner) { + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}credential_usage" (` + + `"workflowId" integer NOT NULL,` + + `"nodeId" varchar NOT NULL,` + + `"credentialId" integer NOT NULL,` + + `"createdAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` + + `"updatedAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` + + `PRIMARY KEY("workflowId", "nodeId", "credentialId"), ` + + `CONSTRAINT "FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, ` + + `CONSTRAINT "FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f" FOREIGN KEY ("credentialId") REFERENCES "${tablePrefix}credentials_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ` + + `);`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index ae0c221c15..b4355a1dc0 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -19,6 +19,7 @@ import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinDat import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole'; import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable'; +import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -42,6 +43,7 @@ const sqliteMigrations = [ CreateCredentialsUserRole1660062385367, CreateWorkflowsEditorRole1663755770892, CreateCredentialUsageTable1665484192211, + RemoveCredentialUsageTable1665754637024, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 8cd5af5069..6ecb04c85c 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -11,7 +11,6 @@ import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { LoggerProxy } from 'n8n-workflow'; import * as TagHelpers from '../TagHelpers'; import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee'; -import { CredentialUsage } from '../databases/entities/CredentialUsage'; // eslint-disable-next-line @typescript-eslint/naming-convention export const EEWorkflowController = express.Router(); @@ -155,29 +154,6 @@ EEWorkflowController.post( }); await transactionManager.save(newSharedWorkflow); - - const credentialUsage: CredentialUsage[] = []; - newWorkflow.nodes.forEach((node) => { - if (!node.credentials) { - return; - } - Object.keys(node.credentials).forEach((credentialType) => { - const credentialId = node.credentials?.[credentialType].id; - if (credentialId) { - const newCredentialusage = new CredentialUsage(); - Object.assign(newCredentialusage, { - credentialId, - nodeId: node.id, - workflowId: savedWorkflow?.id, - }); - credentialUsage.push(newCredentialusage); - } - }); - }); - - if (credentialUsage.length) { - await transactionManager.save(credentialUsage); - } }); if (!savedWorkflow) { @@ -202,3 +178,28 @@ EEWorkflowController.post( }; }), ); + +EEWorkflowController.patch( + '/:id(\\d+)', + ResponseHelper.send(async (req: WorkflowRequest.Update) => { + const { id: workflowId } = req.params; + + const updateData = new WorkflowEntity(); + const { tags, ...rest } = req.body; + Object.assign(updateData, rest); + + const updatedWorkflow = await EEWorkflows.updateWorkflow( + req.user, + updateData, + workflowId, + tags, + ); + + const { id, ...remainder } = updatedWorkflow; + + return { + id: id.toString(), + ...remainder, + }; + }), +); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index dff75a59f3..5a36d6c650 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -33,6 +33,7 @@ import { getLogger } from '../Logger'; import type { WorkflowRequest } from '../requests'; import { getSharedWorkflowIds, isBelowOnboardingThreshold } from '../WorkflowHelpers'; import { EEWorkflowController } from './workflows.controller.ee'; +import { WorkflowsService } from './workflows.services'; import { validate as jsonSchemaValidate } from 'jsonschema'; const activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); @@ -364,128 +365,12 @@ workflowsController.patch( const { tags, ...rest } = req.body; Object.assign(updateData, rest); - const shared = await Db.collections.SharedWorkflow.findOne({ - relations: ['workflow'], - where: whereClause({ - user: req.user, - entityType: 'workflow', - entityId: workflowId, - }), - }); - - if (!shared) { - LoggerProxy.info('User attempted to update a workflow without permissions', { - workflowId, - userId: req.user.id, - }); - throw new ResponseHelper.ResponseError( - `Workflow with ID "${workflowId}" could not be found to be updated.`, - undefined, - 404, - ); - } - - // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(updateData); - - WorkflowHelpers.addNodeIds(updateData); - - await externalHooks.run('workflow.update', [updateData]); - - if (shared.workflow.active) { - // When workflow gets saved always remove it as the triggers could have been - // changed and so the changes would not take effect - await activeWorkflowRunner.remove(workflowId); - } - - if (updateData.settings) { - if (updateData.settings.timezone === 'DEFAULT') { - // Do not save the default timezone - delete updateData.settings.timezone; - } - if (updateData.settings.saveDataErrorExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataErrorExecution; - } - if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataSuccessExecution; - } - if (updateData.settings.saveManualExecutions === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveManualExecutions; - } - if ( - parseInt(updateData.settings.executionTimeout as string, 10) === - config.get('executions.timeout') - ) { - // Do not save when default got set - delete updateData.settings.executionTimeout; - } - } - - if (updateData.name) { - updateData.updatedAt = new Date(); // required due to atomic update - await validateEntity(updateData); - } - - await Db.collections.Workflow.update(workflowId, updateData); - - if (tags && !config.getEnv('workflowTagsDisabled')) { - const tablePrefix = config.getEnv('database.tablePrefix'); - await TagHelpers.removeRelations(workflowId, tablePrefix); - - if (tags.length) { - await TagHelpers.createRelations(workflowId, tags, tablePrefix); - } - } - - const options: FindManyOptions = { - relations: ['tags'], - }; - - if (config.getEnv('workflowTagsDisabled')) { - delete options.relations; - } - - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const updatedWorkflow = await Db.collections.Workflow.findOne(workflowId, options); - - if (updatedWorkflow === undefined) { - throw new ResponseHelper.ResponseError( - `Workflow with ID "${workflowId}" could not be found to be updated.`, - undefined, - 400, - ); - } - - if (updatedWorkflow.tags?.length && tags?.length) { - updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { - requestOrder: tags, - }); - } - - await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow, false); - - if (updatedWorkflow.active) { - // When the workflow is supposed to be active add it again - try { - await externalHooks.run('workflow.activate', [updatedWorkflow]); - await activeWorkflowRunner.add(workflowId, shared.workflow.active ? 'update' : 'activate'); - } catch (error) { - // If workflow could not be activated set it again to inactive - updateData.active = false; - await Db.collections.Workflow.update(workflowId, updateData); - - // Also set it in the returned data - updatedWorkflow.active = false; - - // Now return the original error for UI to display - throw error; - } - } + const updatedWorkflow = await WorkflowsService.updateWorkflow( + req.user, + updateData, + workflowId, + tags, + ); const { id, ...remainder } = updatedWorkflow; diff --git a/packages/cli/src/workflows/workflows.services.ee.ts b/packages/cli/src/workflows/workflows.services.ee.ts index 3ba791a118..5ef6327703 100644 --- a/packages/cli/src/workflows/workflows.services.ee.ts +++ b/packages/cli/src/workflows/workflows.services.ee.ts @@ -1,5 +1,5 @@ import { DeleteResult, EntityManager, In, Not } from 'typeorm'; -import { Db, ICredentialsDb } from '..'; +import { Db, ICredentialsDb, ResponseHelper, WorkflowHelpers } from '..'; import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { User } from '../databases/entities/User'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; @@ -7,6 +7,7 @@ import { RoleService } from '../role/role.service'; import { UserService } from '../user/user.service'; import { WorkflowsService } from './workflows.services'; import { WorkflowWithSharings } from './workflows.types'; +import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee'; export class EEWorkflowsService extends WorkflowsService { static async isOwned( @@ -114,4 +115,33 @@ export class EEWorkflowsService extends WorkflowsService { }); }); } + + static async updateWorkflow( + user: User, + workflow: WorkflowEntity, + workflowId: string, + tags?: string[], + ): Promise { + const previousVersion = await EEWorkflowsService.get({ id: parseInt(workflowId, 10) }); + if (!previousVersion) { + throw new ResponseHelper.ResponseError('Workflow not found', undefined, 404); + } + const allCredentials = await EECredentials.getAll(user); + try { + workflow = WorkflowHelpers.validateWorkflowCredentialUsage( + workflow, + previousVersion, + allCredentials, + ); + } catch (error) { + console.log(error); + throw new ResponseHelper.ResponseError( + 'Invalid workflow credentials - make sure you have access to all credentials and try again.', + undefined, + 400, + ); + } + + return super.updateWorkflow(user, workflow, workflowId, tags); + } } diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 20d89ca075..9e7890e902 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -1,8 +1,20 @@ -import { FindOneOptions, ObjectLiteral } from 'typeorm'; -import { Db } from '..'; +import { LoggerProxy } from 'n8n-workflow'; +import { FindManyOptions, FindOneOptions, ObjectLiteral } from 'typeorm'; +import { + ActiveWorkflowRunner, + Db, + InternalHooksManager, + ResponseHelper, + whereClause, + WorkflowHelpers, +} from '..'; +import config from '../../config'; import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { User } from '../databases/entities/User'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; +import { validateEntity } from '../GenericHelpers'; +import { externalHooks } from '../Server'; +import * as TagHelpers from '../TagHelpers'; export class WorkflowsService { static async getSharing( @@ -34,4 +46,139 @@ export class WorkflowsService { static async get(workflow: Partial, options?: { relations: string[] }) { return Db.collections.Workflow.findOne(workflow, options); } + + static async updateWorkflow( + user: User, + workflow: WorkflowEntity, + workflowId: string, + tags?: string[], + ): Promise { + const shared = await Db.collections.SharedWorkflow.findOne({ + relations: ['workflow'], + where: whereClause({ + user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to update a workflow without permissions', { + workflowId, + userId: user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + // check credentials for old format + await WorkflowHelpers.replaceInvalidCredentials(workflow); + + WorkflowHelpers.addNodeIds(workflow); + + await externalHooks.run('workflow.update', [workflow]); + + if (shared.workflow.active) { + // When workflow gets saved always remove it as the triggers could have been + // changed and so the changes would not take effect + await ActiveWorkflowRunner.getInstance().remove(workflowId); + } + + if (workflow.settings) { + if (workflow.settings.timezone === 'DEFAULT') { + // Do not save the default timezone + delete workflow.settings.timezone; + } + if (workflow.settings.saveDataErrorExecution === 'DEFAULT') { + // Do not save when default got set + delete workflow.settings.saveDataErrorExecution; + } + if (workflow.settings.saveDataSuccessExecution === 'DEFAULT') { + // Do not save when default got set + delete workflow.settings.saveDataSuccessExecution; + } + if (workflow.settings.saveManualExecutions === 'DEFAULT') { + // Do not save when default got set + delete workflow.settings.saveManualExecutions; + } + if ( + parseInt(workflow.settings.executionTimeout as string, 10) === + config.get('executions.timeout') + ) { + // Do not save when default got set + delete workflow.settings.executionTimeout; + } + } + + if (workflow.name) { + workflow.updatedAt = new Date(); // required due to atomic update + await validateEntity(workflow); + } + + await Db.collections.Workflow.update(workflowId, workflow); + + if (tags && !config.getEnv('workflowTagsDisabled')) { + const tablePrefix = config.getEnv('database.tablePrefix'); + await TagHelpers.removeRelations(workflowId, tablePrefix); + + if (tags.length) { + await TagHelpers.createRelations(workflowId, tags, tablePrefix); + } + } + + const options: FindManyOptions = { + relations: ['tags'], + }; + + if (config.getEnv('workflowTagsDisabled')) { + delete options.relations; + } + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the hopefully updated entry. + const updatedWorkflow = await Db.collections.Workflow.findOne(workflowId, options); + + if (updatedWorkflow === undefined) { + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 400, + ); + } + + if (updatedWorkflow.tags?.length && tags?.length) { + updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { + requestOrder: tags, + }); + } + + await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); + void InternalHooksManager.getInstance().onWorkflowSaved(user.id, updatedWorkflow, false); + + if (updatedWorkflow.active) { + // When the workflow is supposed to be active add it again + try { + await externalHooks.run('workflow.activate', [updatedWorkflow]); + await ActiveWorkflowRunner.getInstance().add( + workflowId, + shared.workflow.active ? 'update' : 'activate', + ); + } catch (error) { + // If workflow could not be activated set it again to inactive + workflow.active = false; + await Db.collections.Workflow.update(workflowId, workflow); + + // Also set it in the returned data + updatedWorkflow.active = false; + + // Now return the original error for UI to display + throw error; + } + } + + return updatedWorkflow; + } } diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 455f80ecb5..9872b29914 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -237,7 +237,6 @@ function toTableName(sourceName: CollectionName | MappingName) { return { Credentials: 'credentials_entity', - CredentialUsage: 'credential_usage', Workflow: 'workflow_entity', Execution: 'execution_entity', Tag: 'tag_entity', @@ -642,18 +641,6 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) { }); } -// ---------------------------------- -// credential usage -// ---------------------------------- - -export async function getCredentialUsageInWorkflow(workflowId: number) { - return Db.collections.CredentialUsage.find({ - where: { - workflowId, - }, - }); -} - // ---------------------------------- // connection options // ---------------------------------- diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index 30d304d89c..ff09497544 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -11,6 +11,7 @@ import config from '../../config'; import type { AuthAgent, SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils'; import { randomCredentialPayload } from './shared/random'; +import { INode, INodes } from 'n8n-workflow'; jest.mock('../../src/telemetry'); @@ -72,7 +73,7 @@ describe('PUT /workflows/:id', () => { expect(sharedWorkflows).toHaveLength(2); }); - test('PUT /workflows/:id/share should not fail when sharing with invalid user-id', async () => { + test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const workflow = await createWorkflow({}, owner); @@ -218,12 +219,9 @@ describe('POST /workflows', () => { const response = await authAgent(owner).post('/workflows').send(workflow); expect(response.statusCode).toBe(200); - - const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id); - expect(usedCredentials).toHaveLength(0); }); - it('Should save credential usage when saving a new workflow', async () => { + it('Should save a new workflow with credentials', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); @@ -235,9 +233,6 @@ describe('POST /workflows', () => { const response = await authAgent(owner).post('/workflows').send(workflow); expect(response.statusCode).toBe(200); - - const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id); - expect(usedCredentials).toHaveLength(1); }); it('Should not allow saving a workflow using credential you have no access', async () => { @@ -273,8 +268,6 @@ describe('POST /workflows', () => { const response = await authAgent(owner).post('/workflows').send(workflow); expect(response.statusCode).toBe(200); - const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id); - expect(usedCredentials).toHaveLength(1); }); it('Should allow saving a workflow using a credential owned by others and shared with you', async () => { @@ -291,7 +284,162 @@ describe('POST /workflows', () => { const response = await authAgent(member2).post('/workflows').send(workflow); expect(response.statusCode).toBe(200); - const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id); - expect(usedCredentials).toHaveLength(1); + }); +}); + +describe('PATCH /workflows/:id', () => { + it('Should succeed when saving unchanged workflow nodes', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const workflow = await createWorkflow( + { + 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, + }, + }, + }, + ], + }, + owner, + ); + + const response = await authAgent(owner).patch(`/workflows/${workflow.id}`).send({ + name: 'new name', + }); + + expect(response.statusCode).toBe(200); + }); + + it('Should allow owner to add node containing credential not shared with the owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const workflow = await createWorkflow({}, owner); + + const response = await authAgent(owner) + .patch(`/workflows/${workflow.id}`) + .send({ + 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, + }, + }, + }, + ], + }); + + expect(response.statusCode).toBe(200); + }); + + it('Should prevent member from adding node containing credential inaccessible to member', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const workflow = await createWorkflow({}, member); + + const response = await authAgent(member) + .patch(`/workflows/${workflow.id}`) + .send({ + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: {}, + }, + { + id: 'uuid-12345', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id.toString(), + name: savedCredential.name, + }, + }, + }, + ], + }); + + expect(response.statusCode).toBe(400); + }); + + it('Should succeed but prevent modifying nodes that are read-only for the requester', async () => { + const member1 = await testDb.createUser({ globalRole: globalMemberRole }); + const member2 = await testDb.createUser({ globalRole: globalMemberRole }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + + const originalNodes: INode[] = [ + { + 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 changedNodes: INode[] = [ + { + id: 'uuid-1234', + name: 'End', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.no-op', + typeVersion: 1, + credentials: { + default: { + id: '200', + name: 'fake credential', + }, + }, + }, + ]; + + const workflow = await createWorkflow({ nodes: originalNodes }, member1); + await testDb.shareWorkflowWithUsers(workflow, [member2]); + + const response = await authAgent(member2).patch(`/workflows/${workflow.id}`).send({ + nodes: changedNodes, + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.nodes).toMatchObject(originalNodes); }); }); diff --git a/packages/cli/test/unit/WorkflowHelpers.test.ts b/packages/cli/test/unit/WorkflowHelpers.test.ts new file mode 100644 index 0000000000..1ff6df8724 --- /dev/null +++ b/packages/cli/test/unit/WorkflowHelpers.test.ts @@ -0,0 +1,224 @@ +import { INode, LoggerProxy } from 'n8n-workflow'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import { + getNodesWithInaccessibleCreds, + validateWorkflowCredentialUsage, +} from '../../src/WorkflowHelpers'; +import { getLogger } from '../../src/Logger'; + +const FIRST_CREDENTIAL_ID = '1'; +const SECOND_CREDENTIAL_ID = '2'; +const THIRD_CREDENTIAL_ID = '3'; + +const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a'; +const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226'; +const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da'; + +beforeAll(() => { + LoggerProxy.init(getLogger()); +}); + +describe('WorkflowHelpers', () => { + describe('getNodesWithInaccessibleCreds', () => { + test('Should return an empty list for a workflow without nodes', () => { + const workflow = getWorkflow(); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an empty list for a workflow with nodes without credentials', () => { + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an element for a node with a credential without access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return an empty list for a node with a credential with access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an element for a node with two credentials and mixed access', () => { + const workflow = getWorkflow({ addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one node for a workflow with two nodes and two credentials', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + THIRD_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one element for a workflows with two nodes and one credential', () => { + const workflow = getWorkflow({ + addNodeWithoutCreds: true, + addNodeWithOneCred: true, + addNodeWithTwoCreds: true, + }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one element for a workflows with two nodes and partial credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return two elements for a workflows with two nodes and partial credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(2); + }); + + test('Should return two elements for a workflows with two nodes and no credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(2); + }); + }); + + describe('validateWorkflowCredentialUsage', () => { + it('Should throw error saving a workflow using credential without access', () => { + const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + const previousWorkflowVersion = getWorkflow(); + expect(() => { + validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); + }).toThrow(); + }); + + it('Should not throw error when saving a workflow using credential with access', () => { + const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + const previousWorkflowVersion = getWorkflow(); + expect(() => { + validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ + generateCredentialEntity(FIRST_CREDENTIAL_ID), + ]); + }).not.toThrow(); + }); + + it('Should not throw error when saving a workflow removing node without credential access', () => { + const newWorkflowVersion = getWorkflow(); + const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ + generateCredentialEntity(FIRST_CREDENTIAL_ID), + ]); + }).not.toThrow(); + }); + + it('Should save fine when not making changes to workflow without access', () => { + const workflowWithOneCredential = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + validateWorkflowCredentialUsage(workflowWithOneCredential, workflowWithOneCredential, []); + }).not.toThrow(); + }); + + it('Should throw error saving a workflow adding node without credential access', () => { + const newWorkflowVersion = getWorkflow({ + addNodeWithOneCred: true, + addNodeWithTwoCreds: true, + }); + const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); + }).toThrow(); + }); + }); +}); + +function generateCredentialEntity(credentialId: string) { + const credentialEntity = new CredentialsEntity(); + credentialEntity.id = parseInt(credentialId, 10); + return credentialEntity; +} + +function getWorkflow(options?: { + addNodeWithoutCreds?: boolean; + addNodeWithOneCred?: boolean; + addNodeWithTwoCreds?: boolean; +}) { + const workflow = new WorkflowEntity(); + + workflow.nodes = []; + + if (options?.addNodeWithoutCreds) { + workflow.nodes.push(nodeWithNoCredentials); + } + + if (options?.addNodeWithOneCred) { + workflow.nodes.push(nodeWithOneCredential); + } + + if (options?.addNodeWithTwoCreds) { + workflow.nodes.push(nodeWithTwoCredentials); + } + + return workflow; +} + +const nodeWithNoCredentials: INode = { + id: NODE_WITH_NO_CRED, + name: 'Node with no Credential', + typeVersion: 1, + type: 'n8n-nodes-base.fakeNode', + position: [0, 0], + credentials: {}, + parameters: {}, +}; + +const nodeWithOneCredential: INode = { + id: NODE_WITH_ONE_CRED, + name: 'Node with a single credential', + typeVersion: 1, + type: '', + position: [0, 0], + credentials: { + test: { + id: FIRST_CREDENTIAL_ID, + name: 'First fake credential', + }, + }, + parameters: {}, +}; + +const nodeWithTwoCredentials: INode = { + id: NODE_WITH_TWO_CRED, + name: 'Node with two credentials', + typeVersion: 1, + type: '', + position: [0, 0], + credentials: { + mcTest: { + id: SECOND_CREDENTIAL_ID, + name: 'Second fake credential', + }, + mcTest2: { + id: THIRD_CREDENTIAL_ID, + name: 'Third fake credential', + }, + }, + parameters: {}, +};