feat: add saving new workflow endpoint (#4330) (no-changelog)

* feat: add saving new workflow endpoint
This commit is contained in:
Omar Ajoue 2022-10-13 11:55:58 +02:00 committed by GitHub
parent d4b74bd66a
commit d45bc4999c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 494 additions and 46 deletions

View file

@ -202,6 +202,7 @@ export async function init(
collections.Settings = linkRepository(entities.Settings); collections.Settings = linkRepository(entities.Settings);
collections.InstalledPackages = linkRepository(entities.InstalledPackages); collections.InstalledPackages = linkRepository(entities.InstalledPackages);
collections.InstalledNodes = linkRepository(entities.InstalledNodes); collections.InstalledNodes = linkRepository(entities.InstalledNodes);
collections.CredentialUsage = linkRepository(entities.CredentialUsage);
isInitialized = true; isInitialized = true;

View file

@ -40,6 +40,7 @@ import type { SharedWorkflow } from './databases/entities/SharedWorkflow';
import type { TagEntity } from './databases/entities/TagEntity'; import type { TagEntity } from './databases/entities/TagEntity';
import type { User } from './databases/entities/User'; import type { User } from './databases/entities/User';
import type { WorkflowEntity } from './databases/entities/WorkflowEntity'; import type { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { CredentialUsage } from './databases/entities/CredentialUsage';
export interface IActivationError { export interface IActivationError {
time: number; time: number;
@ -83,6 +84,7 @@ export interface IDatabaseCollections {
Settings: Repository<Settings>; Settings: Repository<Settings>;
InstalledPackages: Repository<InstalledPackages>; InstalledPackages: Repository<InstalledPackages>;
InstalledNodes: Repository<InstalledNodes>; InstalledNodes: Repository<InstalledNodes>;
CredentialUsage: Repository<CredentialUsage>;
} }
export interface IWebhookDb { export interface IWebhookDb {

View file

@ -0,0 +1,72 @@
/* eslint-disable import/no-cycle */
import {
BeforeUpdate,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryColumn,
RelationId,
UpdateDateColumn,
} from 'typeorm';
import { IsDate, IsOptional } from 'class-validator';
import config = require('../../../config');
import { DatabaseType } from '../../index';
import { WorkflowEntity } from './WorkflowEntity';
import { CredentialsEntity } from './CredentialsEntity';
function getTimestampSyntax() {
const dbType = config.get('database.type') as DatabaseType;
const map: { [key in DatabaseType]: string } = {
sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
postgresdb: 'CURRENT_TIMESTAMP(3)',
mysqldb: 'CURRENT_TIMESTAMP(3)',
mariadb: 'CURRENT_TIMESTAMP(3)',
};
return map[dbType];
}
@Entity()
export class CredentialUsage {
@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;
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
@IsOptional() // ignored by validation because set at DB level
@IsDate()
createdAt: Date;
@UpdateDateColumn({
precision: 3,
default: () => getTimestampSyntax(),
onUpdate: getTimestampSyntax(),
})
@IsOptional() // ignored by validation because set at DB level
@IsDate()
updatedAt: Date;
@BeforeUpdate()
setUpdateDate(): void {
this.updatedAt = new Date();
}
}

View file

@ -12,6 +12,7 @@ import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials'; import { SharedCredentials } from './SharedCredentials';
import { InstalledPackages } from './InstalledPackages'; import { InstalledPackages } from './InstalledPackages';
import { InstalledNodes } from './InstalledNodes'; import { InstalledNodes } from './InstalledNodes';
import { CredentialUsage } from './CredentialUsage';
export const entities = { export const entities = {
CredentialsEntity, CredentialsEntity,
@ -26,4 +27,5 @@ export const entities = {
SharedCredentials, SharedCredentials,
InstalledPackages, InstalledPackages,
InstalledNodes, InstalledNodes,
CredentialUsage,
}; };

View file

@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateCredentialUsageTable1665484192213 implements MigrationInterface {
name = 'CreateCredentialUsageTable1665484192213';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
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`,
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`
DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow';
`);
}
}

View file

@ -21,6 +21,7 @@ import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds';
import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData'; import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole'; import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole';
import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -46,4 +47,5 @@ export const mysqlMigrations = [
AddJsonKeyPinData1659895550980, AddJsonKeyPinData1659895550980,
CreateCredentialsUserRole1660062385367, CreateCredentialsUserRole1660062385367,
CreateWorkflowsEditorRole1663755770894, CreateWorkflowsEditorRole1663755770894,
CreateCredentialUsageTable1665484192213,
]; ];

View file

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateCredentialUsageTable1665484192212 implements MigrationInterface {
name = 'CreateCredentialUsageTable1665484192212';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
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 ` +
');',
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
}
}

View file

@ -19,6 +19,7 @@ import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds';
import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData'; import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole'; import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole';
import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable';
export const postgresMigrations = [ export const postgresMigrations = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -42,4 +43,5 @@ export const postgresMigrations = [
AddNodeIds1658932090381, AddNodeIds1658932090381,
AddJsonKeyPinData1659902242948, AddJsonKeyPinData1659902242948,
CreateWorkflowsEditorRole1663755770893, CreateWorkflowsEditorRole1663755770893,
CreateCredentialUsageTable1665484192212,
]; ];

View file

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateCredentialUsageTable1665484192211 implements MigrationInterface {
name = 'CreateCredentialUsageTable1665484192211';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
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 ` +
`);`,
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
}
}

View file

@ -18,6 +18,7 @@ import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds';
import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData'; import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole'; import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole';
import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable';
const sqliteMigrations = [ const sqliteMigrations = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -40,6 +41,7 @@ const sqliteMigrations = [
AddJsonKeyPinData1659888469333, AddJsonKeyPinData1659888469333,
CreateCredentialsUserRole1660062385367, CreateCredentialsUserRole1660062385367,
CreateWorkflowsEditorRole1663755770892, CreateWorkflowsEditorRole1663755770892,
CreateCredentialUsageTable1665484192211,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -1,9 +1,17 @@
import express from 'express'; import express from 'express';
import { Db, ResponseHelper } from '..'; import { Db, InternalHooksManager, ResponseHelper, WorkflowHelpers } from '..';
import config from '../../config'; import config from '../../config';
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
import { validateEntity } from '../GenericHelpers';
import type { WorkflowRequest } from '../requests'; import type { WorkflowRequest } from '../requests';
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper'; import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee'; import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
import { externalHooks } from '../Server';
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 // eslint-disable-next-line @typescript-eslint/naming-convention
export const EEWorkflowController = express.Router(); export const EEWorkflowController = express.Router();
@ -60,15 +68,10 @@ EEWorkflowController.put('/:workflowId/share', async (req: WorkflowRequest.Share
}); });
EEWorkflowController.get( EEWorkflowController.get(
'/:id', '/:id(\\d+)',
(req: WorkflowRequest.Get, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming
ResponseHelper.send(async (req: WorkflowRequest.Get) => { ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params; const { id: workflowId } = req.params;
if (Number.isNaN(Number(workflowId))) {
throw new ResponseHelper.ResponseError(`Workflow ID must be a number.`, undefined, 400);
}
const workflow = await EEWorkflows.get( const workflow = await EEWorkflows.get(
{ id: parseInt(workflowId, 10) }, { id: parseInt(workflowId, 10) },
{ relations: ['shared', 'shared.user', 'shared.role'] }, { relations: ['shared', 'shared.user', 'shared.role'] },
@ -92,3 +95,110 @@ EEWorkflowController.get(
return EEWorkflows.addOwnerAndSharings(workflow); return EEWorkflows.addOwnerAndSharings(workflow);
}), }),
); );
EEWorkflowController.post(
'/',
ResponseHelper.send(async (req: WorkflowRequest.Create) => {
delete req.body.id; // delete if sent
const newWorkflow = new WorkflowEntity();
Object.assign(newWorkflow, req.body);
await validateEntity(newWorkflow);
await externalHooks.run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) {
newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, {
select: ['id', 'name'],
});
}
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
WorkflowHelpers.addNodeIds(newWorkflow);
// This is a new workflow, so we simply check if the user has access to
// all used workflows
const allCredentials = await EECredentials.getAll(req.user);
try {
EEWorkflows.validateCredentialPermissionsToUser(newWorkflow, allCredentials);
} catch (error) {
throw new ResponseHelper.ResponseError(
'The workflow contains credentials that you do not have access to',
undefined,
400,
);
}
let savedWorkflow: undefined | WorkflowEntity;
await Db.transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
const newSharedWorkflow = new SharedWorkflow();
Object.assign(newSharedWorkflow, {
role,
user: req.user,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(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>(credentialUsage);
}
});
if (!savedWorkflow) {
LoggerProxy.error('Failed to create workflow', { userId: req.user.id });
throw new ResponseHelper.ResponseError('Failed to save workflow');
}
if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) {
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds,
});
}
await externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
return {
id: id.toString(),
...rest,
};
}),
);

View file

@ -279,7 +279,7 @@ workflowsController.get(
* GET /workflows/:id * GET /workflows/:id
*/ */
workflowsController.get( workflowsController.get(
'/:id', '/:id(\\d+)',
ResponseHelper.send(async (req: WorkflowRequest.Get) => { ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params; const { id: workflowId } = req.params;

View file

@ -1,5 +1,5 @@
import { DeleteResult, EntityManager, In, Not } from 'typeorm'; import { DeleteResult, EntityManager, In, Not } from 'typeorm';
import { Db } from '..'; import { Db, ICredentialsDb } from '..';
import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
import { User } from '../databases/entities/User'; import { User } from '../databases/entities/User';
import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
@ -94,4 +94,24 @@ export class EEWorkflowsService extends WorkflowsService {
return workflow; return workflow;
} }
static validateCredentialPermissionsToUser(
workflow: WorkflowEntity,
allowedCredentials: ICredentialsDb[],
) {
workflow.nodes.forEach((node) => {
if (!node.credentials) {
return;
}
Object.keys(node.credentials).forEach((credentialType) => {
const credentialId = parseInt(node.credentials?.[credentialType].id ?? '', 10);
const matchedCredential = allowedCredentials.find(
(credential) => credential.id === credentialId,
);
if (!matchedCredential) {
throw new Error('The workflow contains credentials that you do not have access to');
}
});
});
}
} }

View file

@ -681,6 +681,18 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) {
}); });
} }
// ----------------------------------
// credential usage
// ----------------------------------
export async function getCredentialUsageInWorkflow(workflowId: number) {
return Db.collections.CredentialUsage.find({
where: {
workflowId,
},
});
}
// ---------------------------------- // ----------------------------------
// connection options // connection options
// ---------------------------------- // ----------------------------------

View file

@ -10,6 +10,7 @@ import {
ICredentialType, ICredentialType,
IDataObject, IDataObject,
IExecuteFunctions, IExecuteFunctions,
INode,
INodeExecutionData, INodeExecutionData,
INodeParameters, INodeParameters,
INodeTypeData, INodeTypeData,
@ -65,6 +66,8 @@ import type {
InstalledPackagePayload, InstalledPackagePayload,
PostgresSchemaSection, PostgresSchemaSection,
} from './types'; } from './types';
import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity';
import { v4 as uuid } from 'uuid';
/** /**
* Initialize a test server. * Initialize a test server.
@ -698,3 +701,45 @@ export const emptyPackage = () => {
return Promise.resolve(installedPackage); return Promise.resolve(installedPackage);
}; };
// ----------------------------------
// workflow
// ----------------------------------
export function makeWorkflow({
withPinData,
withCredential,
}: {
withPinData: boolean;
withCredential?: { id: string; name: string };
}) {
const workflow = new WorkflowEntity();
const node: INode = {
id: uuid(),
name: 'Spotify',
type: 'n8n-nodes-base.spotify',
parameters: { resource: 'track', operation: 'get', id: '123' },
typeVersion: 1,
position: [740, 240],
};
if (withCredential) {
node.credentials = {
spotifyApi: withCredential,
};
}
workflow.name = 'My Workflow';
workflow.active = false;
workflow.connections = {};
workflow.nodes = [node];
if (withPinData) {
workflow.pinData = MOCK_PINDATA;
}
return workflow;
}
export const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };

View file

@ -8,7 +8,9 @@ import { v4 as uuid } from 'uuid';
import type { Role } from '../../src/databases/entities/Role'; import type { Role } from '../../src/databases/entities/Role';
import config from '../../config'; import config from '../../config';
import type { AuthAgent } from './shared/types'; import type { AuthAgent, SaveCredentialFunction } from './shared/types';
import { makeWorkflow } from './shared/utils';
import { randomCredentialPayload } from './shared/random';
jest.mock('../../src/telemetry'); jest.mock('../../src/telemetry');
@ -20,7 +22,9 @@ let testDbName = '';
let globalOwnerRole: Role; let globalOwnerRole: Role;
let globalMemberRole: Role; let globalMemberRole: Role;
let credentialOwnerRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
let saveCredential: SaveCredentialFunction;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({
@ -32,6 +36,9 @@ beforeAll(async () => {
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
credentialOwnerRole = await testDb.getCredentialOwnerRole();
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
@ -123,21 +130,27 @@ describe('PUT /workflows/:id', () => {
}); });
describe('GET /workflows/:id', () => { describe('GET /workflows/:id', () => {
test('GET should fail with invalid id', async () => { test('GET should fail with invalid id due to route rule', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.get('/workflows/potatoes'); const response = await authAgent(owner).get('/workflows/potatoes');
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(404);
});
test('GET should return 404 for non existing workflow', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const response = await authAgent(owner).get('/workflows/9001');
expect(response.statusCode).toBe(404);
}); });
test('GET should return a workflow with owner', async () => { test('GET should return a workflow with owner', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const workflow = await createWorkflow({}, owner); const workflow = await createWorkflow({}, owner);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data.ownedBy).toMatchObject({ expect(response.body.data.ownedBy).toMatchObject({
@ -154,10 +167,9 @@ describe('GET /workflows/:id', () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole }); const member = await testDb.createUser({ globalRole: globalMemberRole });
const workflow = await createWorkflow({}, owner); const workflow = await createWorkflow({}, owner);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
await testDb.shareWorkflowWithUsers(workflow, [member]); await testDb.shareWorkflowWithUsers(workflow, [member]);
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data.ownedBy).toMatchObject({ expect(response.body.data.ownedBy).toMatchObject({
@ -181,10 +193,9 @@ describe('GET /workflows/:id', () => {
const member1 = await testDb.createUser({ globalRole: globalMemberRole }); const member1 = await testDb.createUser({ globalRole: globalMemberRole });
const member2 = await testDb.createUser({ globalRole: globalMemberRole }); const member2 = await testDb.createUser({ globalRole: globalMemberRole });
const workflow = await createWorkflow({}, owner); const workflow = await createWorkflow({}, owner);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
await testDb.shareWorkflowWithUsers(workflow, [member1, member2]); await testDb.shareWorkflowWithUsers(workflow, [member1, member2]);
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data.ownedBy).toMatchObject({ expect(response.body.data.ownedBy).toMatchObject({
@ -197,3 +208,90 @@ describe('GET /workflows/:id', () => {
expect(response.body.data.sharedWith).toHaveLength(2); expect(response.body.data.sharedWith).toHaveLength(2);
}); });
}); });
describe('POST /workflows', () => {
it('Should create a workflow that uses no credential', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const workflow = makeWorkflow({ withPinData: false });
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 () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
const workflow = makeWorkflow({
withPinData: false,
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
});
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 () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
// Credential belongs to owner, member cannot use it.
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
const workflow = makeWorkflow({
withPinData: false,
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
});
const response = await authAgent(member).post('/workflows').send(workflow);
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe(
'The workflow contains credentials that you do not have access to',
);
});
it('Should allow owner to save a workflow using credential owned by others', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
// Credential belongs to owner, member cannot use it.
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
const workflow = makeWorkflow({
withPinData: false,
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
});
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 () => {
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 });
await testDb.shareCredentialWithUsers(savedCredential, [member2]);
const workflow = makeWorkflow({
withPinData: false,
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
});
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);
});
});

View file

@ -7,6 +7,7 @@ import * as UserManagementHelpers from '../../src/UserManagement/UserManagementH
import type { Role } from '../../src/databases/entities/Role'; import type { Role } from '../../src/databases/entities/Role';
import type { IPinData } from 'n8n-workflow'; import type { IPinData } from 'n8n-workflow';
import { makeWorkflow, MOCK_PINDATA } from './shared/utils';
jest.mock('../../src/telemetry'); jest.mock('../../src/telemetry');
@ -87,29 +88,3 @@ test('GET /workflows/:id should return pin data', async () => {
expect(pinData).toMatchObject(MOCK_PINDATA); expect(pinData).toMatchObject(MOCK_PINDATA);
}); });
function makeWorkflow({ withPinData }: { withPinData: boolean }) {
const workflow = new WorkflowEntity();
workflow.name = 'My Workflow';
workflow.active = false;
workflow.connections = {};
workflow.nodes = [
{
id: 'uuid-1234',
name: 'Spotify',
type: 'n8n-nodes-base.spotify',
parameters: { resource: 'track', operation: 'get', id: '123' },
typeVersion: 1,
position: [740, 240],
},
];
if (withPinData) {
workflow.pinData = MOCK_PINDATA;
}
return workflow;
}
const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };