mirror of
https://github.com/n8n-io/n8n
synced 2026-05-10 13:32:23 +02:00
Co-authored-by: Michael Siega <michael.siega@n8n.io> Co-authored-by: Michael Kret <michael.k@radency.com>
4404 lines
127 KiB
TypeScript
4404 lines
127 KiB
TypeScript
import {
|
|
createTeamProject,
|
|
getPersonalProject,
|
|
linkUserToProject,
|
|
createWorkflow,
|
|
createActiveWorkflow,
|
|
setActiveVersion,
|
|
createWorkflowWithHistory,
|
|
shareWorkflowWithProjects,
|
|
shareWorkflowWithUsers,
|
|
randomCredentialPayload,
|
|
testDb,
|
|
mockInstance,
|
|
} from '@n8n/backend-test-utils';
|
|
import type {
|
|
User,
|
|
ListQueryDb,
|
|
WorkflowFolderUnionFull,
|
|
Role,
|
|
WorkflowHistory,
|
|
WorkflowEntity,
|
|
} from '@n8n/db';
|
|
import {
|
|
ProjectRepository,
|
|
WorkflowHistoryRepository,
|
|
SharedWorkflowRepository,
|
|
WorkflowRepository,
|
|
WorkflowPublishHistoryRepository,
|
|
} from '@n8n/db';
|
|
import { Container } from '@n8n/di';
|
|
import type { Scope } from '@n8n/permissions';
|
|
import { WorkflowValidationService } from '@/workflows/workflow-validation.service';
|
|
import { createFolder } from '@test-integration/db/folders';
|
|
import { DateTime } from 'luxon';
|
|
import { PROJECT_ROOT, type INode, type IPinData, type IWorkflowBase } from 'n8n-workflow';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { saveCredential } from '../shared/db/credentials';
|
|
import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles';
|
|
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
|
|
import { createChatUser, createManyUsers, createMember, createOwner } from '../shared/db/users';
|
|
import { createWorkflowHistoryItem } from '../shared/db/workflow-history';
|
|
import type { SuperAgentTest } from '../shared/types';
|
|
import * as utils from '../shared/utils/';
|
|
import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/';
|
|
|
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
import { CollaborationService } from '@/collaboration/collaboration.service';
|
|
import { EventService } from '@/events/event.service';
|
|
import { License } from '@/license';
|
|
import { ProjectService } from '@/services/project.service.ee';
|
|
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
|
|
|
let owner: User;
|
|
let member: User;
|
|
let anotherMember: User;
|
|
|
|
let authOwnerAgent: SuperAgentTest;
|
|
let authMemberAgent: SuperAgentTest;
|
|
const testServer = utils.setupTestServer({
|
|
endpointGroups: ['workflows'],
|
|
enabledFeatures: ['feat:sharing'],
|
|
quotas: {
|
|
'quota:maxTeamProjects': -1,
|
|
},
|
|
});
|
|
|
|
const { objectContaining, arrayContaining, any } = expect;
|
|
|
|
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
|
|
const workflowValidationService = mockInstance(WorkflowValidationService);
|
|
const collaborationService = mockInstance(CollaborationService);
|
|
|
|
let projectRepository: ProjectRepository;
|
|
let workflowRepository: WorkflowRepository;
|
|
let workflowHistoryRepository: WorkflowHistoryRepository;
|
|
let eventService: EventService;
|
|
let folderListMissingRole: Role;
|
|
let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
|
|
|
|
beforeAll(async () => {
|
|
await utils.initNodeTypes();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await testDb.truncate([
|
|
'SharedWorkflow',
|
|
'ProjectRelation',
|
|
'Folder',
|
|
'WebhookEntity',
|
|
'WorkflowEntity',
|
|
'WorkflowHistory',
|
|
'WorkflowPublishHistory',
|
|
'TagEntity',
|
|
'Project',
|
|
'User',
|
|
]);
|
|
await cleanupRolesAndScopes();
|
|
projectRepository = Container.get(ProjectRepository);
|
|
workflowRepository = Container.get(WorkflowRepository);
|
|
workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
|
eventService = Container.get(EventService);
|
|
workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository);
|
|
owner = await createOwner();
|
|
authOwnerAgent = testServer.authAgentFor(owner);
|
|
member = await createMember();
|
|
authMemberAgent = testServer.authAgentFor(member);
|
|
anotherMember = await createMember();
|
|
|
|
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
|
|
workflowValidationService.validateSubWorkflowReferences.mockResolvedValue({ isValid: true });
|
|
|
|
folderListMissingRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:list'], {
|
|
roleType: 'project',
|
|
displayName: 'Workflow Read-Only',
|
|
description: 'Can only read and list workflows',
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('POST /workflows', () => {
|
|
const testWithPinData = async (withPinData: boolean) => {
|
|
const workflow = makeWorkflow({ withPinData });
|
|
const response = await authOwnerAgent.post('/workflows').send(workflow);
|
|
expect(response.statusCode).toBe(200);
|
|
return (response.body.data as { pinData: IPinData }).pinData;
|
|
};
|
|
|
|
test('should store pin data for node in workflow', async () => {
|
|
const pinData = await testWithPinData(true);
|
|
expect(pinData).toMatchObject(MOCK_PINDATA);
|
|
});
|
|
|
|
test('should set pin data to null if no pin data', async () => {
|
|
const pinData = await testWithPinData(false);
|
|
expect(pinData).toBeNull();
|
|
});
|
|
|
|
test('should retain accept `workflow.id`', async () => {
|
|
const payload = {
|
|
id: 'HDssU5Ce250UWyLg_MNG4',
|
|
name: 'name',
|
|
nodes: [],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {},
|
|
active: false,
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload).expect(200);
|
|
|
|
expect(response.body.data.id).toBe(payload.id);
|
|
});
|
|
|
|
test('fails if a workflow with that id already exists', async () => {
|
|
const payload1 = {
|
|
id: 'HDssU5Ce250UWyLg_MNG4',
|
|
name: 'testing with context',
|
|
nodes: [],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {},
|
|
active: false,
|
|
};
|
|
const payload2 = { ...payload1, name: 'different name' };
|
|
|
|
await authMemberAgent.post('/workflows').send(payload1).expect(200);
|
|
const response = await authMemberAgent.post('/workflows').send(payload2);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
test('should return scopes on created workflow', async () => {
|
|
const payload = {
|
|
name: 'testing',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
saveManualExecutions: true,
|
|
saveDataErrorExecution: 'all',
|
|
saveDataSuccessExecution: 'all',
|
|
executionTimeout: 3600,
|
|
timezone: 'America/New_York',
|
|
},
|
|
active: false,
|
|
activeVersionId: null,
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id, scopes },
|
|
} = response.body;
|
|
|
|
expect(id).toBeDefined();
|
|
expect(scopes).toEqual(
|
|
[
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
test('should create workflow with uiContext parameter', async () => {
|
|
const payload = {
|
|
name: 'testing with context',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {},
|
|
active: false,
|
|
activeVersionId: null,
|
|
uiContext: 'workflow_list',
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id, name },
|
|
} = response.body;
|
|
|
|
expect(id).toBeDefined();
|
|
expect(name).toBe('testing with context');
|
|
});
|
|
|
|
test('should always create workflow history version', async () => {
|
|
const payload = {
|
|
name: 'testing',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
saveManualExecutions: true,
|
|
saveDataErrorExecution: 'all',
|
|
saveDataSuccessExecution: 'all',
|
|
executionTimeout: 3600,
|
|
timezone: 'America/New_York',
|
|
},
|
|
active: false,
|
|
activeVersionId: null,
|
|
};
|
|
|
|
const response = await authOwnerAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id },
|
|
} = response.body;
|
|
|
|
expect(id).toBeDefined();
|
|
expect(await workflowHistoryRepository.count({ where: { workflowId: id } })).toBe(1);
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
workflowId: id,
|
|
},
|
|
});
|
|
expect(historyVersion).not.toBeNull();
|
|
expect(historyVersion!.connections).toEqual(payload.connections);
|
|
expect(historyVersion!.nodes).toEqual(payload.nodes);
|
|
});
|
|
|
|
test('should set autosaved: true in workflow history when autosaved parameter is sent', async () => {
|
|
const payload = {
|
|
name: 'testing autosave',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {},
|
|
active: false,
|
|
activeVersionId: null,
|
|
autosaved: true,
|
|
};
|
|
|
|
const response = await authOwnerAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(id).toBeDefined();
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
workflowId: id,
|
|
},
|
|
});
|
|
expect(historyVersion).not.toBeNull();
|
|
expect(historyVersion!.autosaved).toBe(true);
|
|
});
|
|
|
|
test('should set autosaved: false in workflow history when autosaved is not sent', async () => {
|
|
const payload = {
|
|
name: 'testing manual save',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {},
|
|
active: false,
|
|
activeVersionId: null,
|
|
};
|
|
|
|
const response = await authOwnerAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(id).toBeDefined();
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
workflowId: id,
|
|
},
|
|
});
|
|
expect(historyVersion).not.toBeNull();
|
|
expect(historyVersion!.autosaved).toBe(false);
|
|
});
|
|
|
|
test('should create workflow as inactive even when active fields are provided', async () => {
|
|
const activeWorkflow = await createActiveWorkflow({}, owner);
|
|
const activeVersion = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
versionId: activeWorkflow.activeVersionId!,
|
|
},
|
|
});
|
|
|
|
const { id: existingWorkflowId, ...workflowWithoutId } = activeWorkflow;
|
|
const payload = {
|
|
...workflowWithoutId,
|
|
// Deliberately set active fields
|
|
active: true,
|
|
activeVersionId: activeWorkflow.activeVersionId,
|
|
activeVersion,
|
|
};
|
|
|
|
const response = await authOwnerAgent.post('/workflows').send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id, versionId, activeVersionId, active },
|
|
} = response.body;
|
|
|
|
expect(id).toBeDefined();
|
|
expect(versionId).toBeDefined();
|
|
// New version should be created
|
|
expect(versionId).not.toBe(activeWorkflow.versionId);
|
|
expect(activeVersionId).toBeNull();
|
|
expect(active).toBe(false);
|
|
|
|
// Verify in database that workflow is completely inactive
|
|
const workflow = await workflowRepository.findOneBy({ id });
|
|
expect(workflow?.activeVersionId).toBeNull();
|
|
expect(workflow?.active).toBe(false);
|
|
|
|
// Verify workflow history was created
|
|
const historyCount = await workflowHistoryRepository.count({ where: { workflowId: id } });
|
|
expect(historyCount).toBe(1);
|
|
});
|
|
|
|
test('create workflow in personal project by default', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const tag = await createTag({ name: 'A' });
|
|
const workflow = makeWorkflow();
|
|
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const response = await authOwnerAgent
|
|
.post('/workflows')
|
|
.send({ ...workflow, tags: [tag.id] })
|
|
.expect(200);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
await Container.get(SharedWorkflowRepository).findOneOrFail({
|
|
where: {
|
|
projectId: personalProject.id,
|
|
workflowId: response.body.data.id,
|
|
},
|
|
});
|
|
expect(response.body.data).toMatchObject({
|
|
active: false,
|
|
activeVersionId: null,
|
|
id: expect.any(String),
|
|
name: workflow.name,
|
|
sharedWithProjects: [],
|
|
usedCredentials: [],
|
|
homeProject: {
|
|
id: personalProject.id,
|
|
name: personalProject.name,
|
|
type: personalProject.type,
|
|
},
|
|
tags: [{ id: tag.id, name: tag.name }],
|
|
});
|
|
expect(response.body.data.shared).toBeUndefined();
|
|
});
|
|
|
|
test('creates workflow in a specific project if the projectId is passed', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const tag = await createTag({ name: 'A' });
|
|
const workflow = makeWorkflow();
|
|
const project = await projectRepository.save(
|
|
projectRepository.create({
|
|
name: 'Team Project',
|
|
type: 'team',
|
|
}),
|
|
);
|
|
await Container.get(ProjectService).addUser(project.id, {
|
|
userId: owner.id,
|
|
role: 'project:admin',
|
|
});
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const response = await authOwnerAgent
|
|
.post('/workflows')
|
|
.send({ ...workflow, projectId: project.id, tags: [tag.id] })
|
|
.expect(200);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
await Container.get(SharedWorkflowRepository).findOneOrFail({
|
|
where: {
|
|
projectId: project.id,
|
|
workflowId: response.body.data.id,
|
|
},
|
|
});
|
|
expect(response.body.data).toMatchObject({
|
|
active: false,
|
|
activeVersionId: null,
|
|
id: expect.any(String),
|
|
name: workflow.name,
|
|
sharedWithProjects: [],
|
|
usedCredentials: [],
|
|
homeProject: {
|
|
id: project.id,
|
|
name: project.name,
|
|
type: project.type,
|
|
},
|
|
tags: [{ id: tag.id, name: tag.name }],
|
|
});
|
|
expect(response.body.data.shared).toBeUndefined();
|
|
});
|
|
|
|
test('does not create the workflow in a specific project if the user is not part of the project', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const workflow = makeWorkflow();
|
|
const project = await projectRepository.save(
|
|
projectRepository.create({
|
|
name: 'Team Project',
|
|
type: 'team',
|
|
}),
|
|
);
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
await testServer
|
|
.authAgentFor(member)
|
|
.post('/workflows')
|
|
.send({ ...workflow, projectId: project.id })
|
|
//
|
|
// ASSERT
|
|
//
|
|
.expect(400, {
|
|
code: 400,
|
|
message: "You don't have the permissions to save the workflow in this project.",
|
|
});
|
|
});
|
|
|
|
test('does not create the workflow in a specific project if the user does not have the right role to do so', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const workflow = makeWorkflow();
|
|
const project = await projectRepository.save(
|
|
projectRepository.create({
|
|
name: 'Team Project',
|
|
type: 'team',
|
|
}),
|
|
);
|
|
await Container.get(ProjectService).addUser(project.id, {
|
|
userId: member.id,
|
|
role: 'project:viewer',
|
|
});
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
await testServer
|
|
.authAgentFor(member)
|
|
.post('/workflows')
|
|
.send({ ...workflow, projectId: project.id })
|
|
//
|
|
// ASSERT
|
|
//
|
|
.expect(400, {
|
|
code: 400,
|
|
message: "You don't have the permissions to save the workflow in this project.",
|
|
});
|
|
});
|
|
|
|
test('does not create the workflow in a personal project if the user is chat user', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const chatUser = await createChatUser();
|
|
const workflow = makeWorkflow();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
await testServer
|
|
.authAgentFor(chatUser)
|
|
.post('/workflows')
|
|
.send({ ...workflow })
|
|
//
|
|
// ASSERT
|
|
//
|
|
.expect(400, {
|
|
code: 400,
|
|
message: "You don't have the permissions to save the workflow in this project.",
|
|
});
|
|
});
|
|
|
|
test('create link workflow with folder if one is provided', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
const folder = await createFolder(personalProject, { name: 'Folder 1' });
|
|
|
|
const workflow = makeWorkflow();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const response = await authOwnerAgent
|
|
.post('/workflows')
|
|
.send({ ...workflow, parentFolderId: folder.id });
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
|
|
expect(response.body.data).toMatchObject({
|
|
active: false,
|
|
activeVersionId: null,
|
|
id: expect.any(String),
|
|
name: workflow.name,
|
|
sharedWithProjects: [],
|
|
usedCredentials: [],
|
|
homeProject: {
|
|
id: personalProject.id,
|
|
name: personalProject.name,
|
|
type: personalProject.type,
|
|
},
|
|
parentFolder: {
|
|
id: folder.id,
|
|
name: folder.name,
|
|
},
|
|
});
|
|
expect(response.body.data.shared).toBeUndefined();
|
|
});
|
|
|
|
test('create workflow without parent folder if no folder is provided', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
const workflow = makeWorkflow();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const response = await authOwnerAgent
|
|
.post('/workflows')
|
|
.send({ ...workflow })
|
|
.expect(200);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
|
|
expect(response.body.data).toMatchObject({
|
|
active: false,
|
|
activeVersionId: null,
|
|
id: expect.any(String),
|
|
name: workflow.name,
|
|
sharedWithProjects: [],
|
|
usedCredentials: [],
|
|
homeProject: {
|
|
id: personalProject.id,
|
|
name: personalProject.name,
|
|
type: personalProject.type,
|
|
},
|
|
parentFolder: null,
|
|
});
|
|
expect(response.body.data.shared).toBeUndefined();
|
|
});
|
|
|
|
test('create workflow without parent is provided folder does not exist in the project', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
const workflow = makeWorkflow();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const response = await authOwnerAgent
|
|
.post('/workflows')
|
|
.send({ ...workflow, parentFolderId: 'non-existing-folder-id' })
|
|
.expect(200);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
|
|
expect(response.body.data).toMatchObject({
|
|
active: false,
|
|
activeVersionId: null,
|
|
id: expect.any(String),
|
|
name: workflow.name,
|
|
sharedWithProjects: [],
|
|
usedCredentials: [],
|
|
homeProject: {
|
|
id: personalProject.id,
|
|
name: personalProject.name,
|
|
type: personalProject.type,
|
|
},
|
|
parentFolder: null,
|
|
});
|
|
expect(response.body.data.shared).toBeUndefined();
|
|
});
|
|
|
|
describe('Security: Mass Assignment Protection', () => {
|
|
test.each([
|
|
{
|
|
field: 'triggerCount' as const,
|
|
maliciousValue: 999,
|
|
expectedValue: 0,
|
|
description: 'billing bypass protection',
|
|
},
|
|
{
|
|
field: 'versionCounter' as const,
|
|
maliciousValue: 999,
|
|
expectedValue: 1,
|
|
description: 'versioning manipulation',
|
|
},
|
|
{
|
|
field: 'isArchived' as const,
|
|
maliciousValue: true,
|
|
expectedValue: false,
|
|
description: 'archived workflow creation',
|
|
},
|
|
])(
|
|
'should ignore $field field sent via API ($description)',
|
|
async ({ field, maliciousValue, expectedValue }) => {
|
|
const payload = {
|
|
name: 'Test Workflow',
|
|
nodes: [],
|
|
connections: {},
|
|
[field]: maliciousValue,
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload).expect(200);
|
|
|
|
const createdWorkflow = await workflowRepository.findOneBy({
|
|
id: response.body.data.id,
|
|
});
|
|
|
|
expect(createdWorkflow?.[field]).toBe(expectedValue);
|
|
},
|
|
);
|
|
|
|
test('should prevent setting activeVersionId via API', async () => {
|
|
const maliciousVersionId = uuid();
|
|
const payload = {
|
|
name: 'Test Workflow',
|
|
nodes: [],
|
|
connections: {},
|
|
activeVersionId: maliciousVersionId,
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload).expect(200);
|
|
|
|
const createdWorkflow = await workflowRepository.findOneBy({
|
|
id: response.body.data.id,
|
|
});
|
|
|
|
expect(createdWorkflow?.activeVersionId).toBeNull();
|
|
});
|
|
|
|
test('should always create workflow as inactive regardless of active flag', async () => {
|
|
const payload = {
|
|
name: 'Test Workflow',
|
|
nodes: [],
|
|
connections: {},
|
|
active: true, // Attempt to create active workflow
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload).expect(200);
|
|
|
|
const createdWorkflow = await workflowRepository.findOneBy({
|
|
id: response.body.data.id,
|
|
});
|
|
|
|
// Workflow should always be created as inactive
|
|
expect(createdWorkflow?.active).toBe(false);
|
|
expect(response.body.data.active).toBe(false);
|
|
});
|
|
|
|
test('should allow setting legitimate fields like name, nodes, connections, settings', async () => {
|
|
const payload = {
|
|
name: 'Legitimate Workflow',
|
|
description: 'A legitimate workflow',
|
|
nodes: [
|
|
{
|
|
id: 'test-node',
|
|
name: 'Test Node',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [250, 300],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
},
|
|
meta: { testMeta: 'value' },
|
|
};
|
|
|
|
const response = await authMemberAgent.post('/workflows').send(payload).expect(200);
|
|
|
|
const createdWorkflow = await workflowRepository.findOneBy({
|
|
id: response.body.data.id,
|
|
});
|
|
|
|
// Legitimate fields should be set correctly
|
|
expect(createdWorkflow?.name).toBe('Legitimate Workflow');
|
|
expect(createdWorkflow?.description).toBe('A legitimate workflow');
|
|
expect(createdWorkflow?.nodes).toHaveLength(1);
|
|
expect(createdWorkflow?.settings).toMatchObject({ saveExecutionProgress: true });
|
|
expect(createdWorkflow?.meta).toMatchObject({ testMeta: 'value' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows/:workflowId', () => {
|
|
test('should return pin data', async () => {
|
|
const workflow = makeWorkflow({ withPinData: true });
|
|
const workflowCreationResponse = await authOwnerAgent.post('/workflows').send(workflow);
|
|
|
|
const { id } = workflowCreationResponse.body.data as { id: string };
|
|
const workflowRetrievalResponse = await authOwnerAgent.get(`/workflows/${id}`);
|
|
|
|
expect(workflowRetrievalResponse.statusCode).toBe(200);
|
|
const { pinData } = workflowRetrievalResponse.body.data as { pinData: IPinData };
|
|
expect(pinData).toMatchObject(MOCK_PINDATA);
|
|
});
|
|
|
|
test('should return tags', async () => {
|
|
const tag = await createTag({ name: 'A' });
|
|
const workflow = await createWorkflow({ tags: [tag] }, owner);
|
|
|
|
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200);
|
|
|
|
expect(response.body.data).toMatchObject({
|
|
tags: [expect.objectContaining({ id: tag.id, name: tag.name })],
|
|
});
|
|
});
|
|
|
|
test('should return active version with workflowPublishHistory', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200);
|
|
|
|
const { data: responseData } = response.body as { data: { activeVersion: WorkflowHistory } };
|
|
const { activeVersion } = responseData;
|
|
|
|
expect(activeVersion).toMatchObject({
|
|
versionId: workflow.activeVersionId,
|
|
workflowId: workflow.id,
|
|
});
|
|
expect(activeVersion.workflowPublishHistory).toHaveLength(1);
|
|
expect(activeVersion.workflowPublishHistory[0]).toMatchObject({
|
|
event: 'activated',
|
|
versionId: workflow.activeVersionId,
|
|
});
|
|
});
|
|
|
|
test('should return parent folder', async () => {
|
|
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const folder1 = await createFolder(personalProject, { name: 'Folder 1' });
|
|
|
|
const folder2 = await createFolder(personalProject, {
|
|
name: 'Folder 2',
|
|
parentFolder: folder1,
|
|
});
|
|
|
|
const workflow1 = await createWorkflow({ parentFolder: folder2 }, owner);
|
|
|
|
const workflow2 = await createWorkflow({}, owner);
|
|
|
|
const workflow3 = await createWorkflow({ parentFolder: folder1 }, owner);
|
|
|
|
const workflowInNestedFolderWithGrantParent = await authOwnerAgent
|
|
.get(`/workflows/${workflow1.id}`)
|
|
.expect(200);
|
|
|
|
expect(workflowInNestedFolderWithGrantParent.body.data).toMatchObject({
|
|
parentFolder: expect.objectContaining({
|
|
id: folder2.id,
|
|
name: folder2.name,
|
|
parentFolderId: folder1.id,
|
|
}),
|
|
});
|
|
|
|
const workflowInProjectRoot = await authOwnerAgent
|
|
.get(`/workflows/${workflow2.id}`)
|
|
.expect(200);
|
|
|
|
expect(workflowInProjectRoot.body.data).toMatchObject({
|
|
parentFolder: null,
|
|
});
|
|
|
|
const workflowInNestedFolder = await authOwnerAgent
|
|
.get(`/workflows/${workflow3.id}`)
|
|
.expect(200);
|
|
|
|
expect(workflowInNestedFolder.body.data).toMatchObject({
|
|
parentFolder: expect.objectContaining({
|
|
id: folder1.id,
|
|
name: folder1.name,
|
|
parentFolderId: null,
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows/:workflowId/exists', () => {
|
|
test('should return true when workflow exists and user has access', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.get(`/workflows/${workflow.id}/exists`).expect(200);
|
|
|
|
expect(response.body).toEqual({ data: { exists: true } });
|
|
});
|
|
|
|
test('should return false when workflow does not exist', async () => {
|
|
const nonExistentId = uuid();
|
|
|
|
const response = await authOwnerAgent.get(`/workflows/${nonExistentId}/exists`).expect(200);
|
|
|
|
expect(response.body).toEqual({ data: { exists: false } });
|
|
});
|
|
|
|
test('should return true when workflow exists even if user does not have access', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authMemberAgent.get(`/workflows/${workflow.id}/exists`).expect(200);
|
|
|
|
expect(response.body).toEqual({ data: { exists: true } });
|
|
});
|
|
|
|
test('should return true when workflow is shared with user', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
|
member.id,
|
|
);
|
|
|
|
await shareWorkflowWithProjects(workflow, [{ project: memberPersonalProject }]);
|
|
|
|
const response = await authMemberAgent.get(`/workflows/${workflow.id}/exists`).expect(200);
|
|
|
|
expect(response.body).toEqual({ data: { exists: true } });
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows', () => {
|
|
test('should return zero workflows if none exist', async () => {
|
|
const response = await authOwnerAgent.get('/workflows').expect(200);
|
|
|
|
expect(response.body).toEqual({ count: 0, data: [] });
|
|
});
|
|
|
|
test('should return workflows', async () => {
|
|
const credential = await saveCredential(randomCredentialPayload(), {
|
|
user: owner,
|
|
role: 'credential:owner',
|
|
});
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const nodes: INode[] = [
|
|
{
|
|
id: uuid(),
|
|
name: 'Action Network',
|
|
type: 'n8n-nodes-base.actionNetwork',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
credentials: {
|
|
actionNetworkApi: {
|
|
id: credential.id,
|
|
name: credential.name,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const tag = await createTag({ name: 'A' });
|
|
|
|
await createWorkflow({ name: 'First', nodes, tags: [tag] }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
const response = await authOwnerAgent.get('/workflows').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
objectContaining({
|
|
id: any(String),
|
|
name: 'First',
|
|
active: false,
|
|
activeVersionId: null,
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
tags: [{ id: any(String), name: 'A' }],
|
|
versionId: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
}),
|
|
objectContaining({
|
|
id: any(String),
|
|
name: 'Second',
|
|
active: false,
|
|
activeVersionId: null,
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
tags: [],
|
|
versionId: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
}),
|
|
]),
|
|
});
|
|
|
|
const found = response.body.data.find(
|
|
(w: ListQueryDb.Workflow.WithOwnership) => w.name === 'First',
|
|
);
|
|
|
|
expect(found.nodes).toBeUndefined();
|
|
expect(found.sharedWithProjects).toHaveLength(0);
|
|
expect(found.usedCredentials).toBeUndefined();
|
|
});
|
|
|
|
test('should return workflows with scopes when ?includeScopes=true', async () => {
|
|
const [member1, member2] = await createManyUsers(2, {
|
|
role: { slug: 'global:member' },
|
|
});
|
|
|
|
const teamProject = await createTeamProject(undefined, member1);
|
|
await linkUserToProject(member2, teamProject, 'project:editor');
|
|
|
|
const credential = await saveCredential(randomCredentialPayload(), {
|
|
user: owner,
|
|
role: 'credential:owner',
|
|
});
|
|
|
|
const nodes: INode[] = [
|
|
{
|
|
id: uuid(),
|
|
name: 'Action Network',
|
|
type: 'n8n-nodes-base.actionNetwork',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
credentials: {
|
|
actionNetworkApi: {
|
|
id: credential.id,
|
|
name: credential.name,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const tag = await createTag({ name: 'A' });
|
|
|
|
const [savedWorkflow1, savedWorkflow2] = await Promise.all([
|
|
createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject),
|
|
createWorkflow({ name: 'Second' }, member2),
|
|
]);
|
|
|
|
await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]);
|
|
|
|
{
|
|
const response = await testServer.authAgentFor(member1).get('/workflows?includeScopes=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(2);
|
|
|
|
const workflows = response.body.data as Array<IWorkflowBase & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual(
|
|
[
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:read',
|
|
'workflow:update',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:publish',
|
|
].sort(),
|
|
);
|
|
}
|
|
|
|
{
|
|
const response = await testServer.authAgentFor(member2).get('/workflows?includeScopes=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(2);
|
|
|
|
const workflows = response.body.data as Array<IWorkflowBase & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual([
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:update',
|
|
]);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
}
|
|
|
|
{
|
|
const response = await testServer.authAgentFor(owner).get('/workflows?includeScopes=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(2);
|
|
|
|
const workflows = response.body.data as Array<IWorkflowBase & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual(
|
|
[
|
|
'workflow:create',
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:list',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:create',
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:list',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
}
|
|
});
|
|
|
|
describe('filter', () => {
|
|
test('should filter workflows by field: query', async () => {
|
|
await createWorkflow({ name: 'First', description: 'A workflow' }, owner);
|
|
await createWorkflow({ name: 'Second', description: 'Also a workflow' }, owner);
|
|
await createWorkflow({ name: 'Third', description: 'My first workflow' }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={"query":"first"}')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: [objectContaining({ name: 'First' }), objectContaining({ name: 'Third' })],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows by field: active', async () => {
|
|
await createActiveWorkflow({}, owner);
|
|
await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "active": true }')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 1,
|
|
data: [objectContaining({ active: true, activeVersionId: expect.any(String) })],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows by field: availableInMCP', async () => {
|
|
const workflow1 = await createWorkflow({ settings: { availableInMCP: true } }, owner);
|
|
await createWorkflow({ settings: {} }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "availableInMCP": true }')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 1,
|
|
data: [objectContaining({ id: workflow1.id })],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows by field: tags (AND operator)', async () => {
|
|
const workflow1 = await createWorkflow({ name: 'First' }, owner);
|
|
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
const baseDate = DateTime.now();
|
|
|
|
await createTag(
|
|
{
|
|
name: 'A',
|
|
createdAt: baseDate.toJSDate(),
|
|
},
|
|
workflow1,
|
|
);
|
|
await createTag(
|
|
{
|
|
name: 'B',
|
|
createdAt: baseDate.plus({ seconds: 1 }).toJSDate(),
|
|
},
|
|
workflow1,
|
|
);
|
|
|
|
const tagC = await createTag({ name: 'C' }, workflow2);
|
|
|
|
await assignTagToWorkflow(tagC, workflow2);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "tags": ["A", "B"] }')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 1,
|
|
data: [
|
|
objectContaining({
|
|
name: 'First',
|
|
tags: expect.arrayContaining([
|
|
{ id: any(String), name: 'A' },
|
|
{ id: any(String), name: 'B' },
|
|
]),
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows by projectId', async () => {
|
|
const workflow = await createWorkflow({ name: 'First' }, owner);
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const response1 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "projectId": "${pp.id}" }`)
|
|
.expect(200);
|
|
|
|
expect(response1.body.data).toHaveLength(1);
|
|
expect(response1.body.data[0].id).toBe(workflow.id);
|
|
|
|
const response2 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "projectId": "Non-Existing Project ID" }')
|
|
.expect(200);
|
|
|
|
expect(response2.body.data).toHaveLength(0);
|
|
});
|
|
|
|
test('should filter by personal project and return only workflows where the user is owner', async () => {
|
|
const workflow = await createWorkflow({ name: 'First' }, owner);
|
|
const workflow2 = await createWorkflow({ name: 'Second' }, member);
|
|
await shareWorkflowWithUsers(workflow2, [owner]);
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const response1 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "projectId": "${pp.id}" }`)
|
|
.expect(200);
|
|
|
|
expect(response1.body.data).toHaveLength(1);
|
|
expect(response1.body.data[0].id).toBe(workflow.id);
|
|
|
|
const response2 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "projectId": "Non-Existing Project ID" }')
|
|
.expect(200);
|
|
|
|
expect(response2.body.data).toHaveLength(0);
|
|
|
|
const response3 = await authOwnerAgent.get('/workflows').query('filter={}').expect(200);
|
|
expect(response3.body.data).toHaveLength(2);
|
|
});
|
|
|
|
test('should filter by personal project and return only workflows where the user is member', async () => {
|
|
const workflow = await createWorkflow({ name: 'First' }, member);
|
|
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
|
|
await shareWorkflowWithUsers(workflow2, [member]);
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(member.id);
|
|
|
|
const response1 = await authMemberAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "projectId": "${pp.id}" }`)
|
|
.expect(200);
|
|
|
|
expect(response1.body.data).toHaveLength(1);
|
|
expect(response1.body.data[0].id).toBe(workflow.id);
|
|
|
|
const response2 = await authMemberAgent
|
|
.get('/workflows')
|
|
.query('filter={ "projectId": "Non-Existing Project ID" }')
|
|
.expect(200);
|
|
|
|
expect(response2.body.data).toHaveLength(0);
|
|
|
|
const response3 = await authMemberAgent.get('/workflows').query('filter={}').expect(200);
|
|
expect(response3.body.data).toHaveLength(2);
|
|
});
|
|
|
|
test('should filter workflows by parentFolderId', async () => {
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const folder1 = await createFolder(pp, { name: 'Folder 1' });
|
|
|
|
const workflow1 = await createWorkflow({ name: 'First', parentFolder: folder1 }, owner);
|
|
|
|
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
const response1 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "parentFolderId": "${folder1.id}" }`)
|
|
.expect(200);
|
|
|
|
expect(response1.body.data).toHaveLength(1);
|
|
expect(response1.body.data[0].id).toBe(workflow1.id);
|
|
|
|
// if not provided, looks for workflows without a parentFolder
|
|
const response2 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "parentFolderId": "0" }');
|
|
expect(200);
|
|
|
|
expect(response2.body.data).toHaveLength(1);
|
|
expect(response2.body.data[0].id).toBe(workflow2.id);
|
|
});
|
|
|
|
test('should filter workflows by nodeTypes', async () => {
|
|
const httpWorkflow = await createWorkflow(
|
|
{
|
|
name: 'HTTP Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const slackWorkflow = await createWorkflow(
|
|
{
|
|
name: 'Slack Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const mixedWorkflow = await createWorkflow(
|
|
{
|
|
name: 'Mixed Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
{
|
|
id: uuid(),
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [100, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
// Filter by single node type
|
|
const httpResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&select=["nodes"]')
|
|
.expect(200);
|
|
|
|
expect(httpResponse.body.data).toHaveLength(2);
|
|
const httpWorkflowIds = httpResponse.body.data.map((w: any) => w.id);
|
|
expect(httpWorkflowIds).toContain(httpWorkflow.id);
|
|
expect(httpWorkflowIds).toContain(mixedWorkflow.id);
|
|
expect(httpResponse.body.data[0].nodes).toHaveLength(1);
|
|
expect(httpResponse.body.data[1].nodes).toHaveLength(2);
|
|
|
|
// Filter by multiple node types (OR operation - returns workflows containing ANY of the specified node types)
|
|
const multipleResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"] }')
|
|
.expect(200);
|
|
|
|
expect(multipleResponse.body.data).toHaveLength(3);
|
|
const multipleWorkflowIds = multipleResponse.body.data.map((w: any) => w.id);
|
|
expect(multipleWorkflowIds).toContain(httpWorkflow.id);
|
|
expect(multipleWorkflowIds).toContain(slackWorkflow.id);
|
|
expect(multipleWorkflowIds).toContain(mixedWorkflow.id);
|
|
|
|
// Filter by non-existent node type
|
|
const emptyResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "nodeTypes": ["n8n-nodes-base.nonExistent"] }')
|
|
.expect(200);
|
|
|
|
expect(emptyResponse.body.data).toHaveLength(0);
|
|
});
|
|
|
|
test('should filter workflows by triggerNodeTypes', async () => {
|
|
const executeWorkflowTriggerWorkflow = await createWorkflow(
|
|
{
|
|
name: 'Subworkflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'When Executed by Another Workflow',
|
|
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
|
parameters: {
|
|
inputSource: 'passthrough',
|
|
},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const errorTriggerWorkflow = await createWorkflow(
|
|
{
|
|
name: 'Error Handler',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'When Workflow Errors',
|
|
type: 'n8n-nodes-base.errorTrigger',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const scheduleTriggerWorkflow = await createWorkflow(
|
|
{
|
|
name: 'Normal Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Schedule Trigger',
|
|
type: 'n8n-nodes-base.scheduleTrigger',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
// Filter by Execute Workflow Trigger (single type in array)
|
|
const executeWorkflowResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "triggerNodeTypes": ["n8n-nodes-base.executeWorkflowTrigger"] }')
|
|
.expect(200);
|
|
|
|
expect(executeWorkflowResponse.body.data).toHaveLength(1);
|
|
expect(executeWorkflowResponse.body.data[0].id).toBe(executeWorkflowTriggerWorkflow.id);
|
|
expect(executeWorkflowResponse.body.data[0].name).toBe('Subworkflow');
|
|
|
|
// Filter by Error Trigger (single type in array)
|
|
const errorTriggerResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "triggerNodeTypes": ["n8n-nodes-base.errorTrigger"] }')
|
|
.expect(200);
|
|
|
|
expect(errorTriggerResponse.body.data).toHaveLength(1);
|
|
expect(errorTriggerResponse.body.data[0].id).toBe(errorTriggerWorkflow.id);
|
|
expect(errorTriggerResponse.body.data[0].name).toBe('Error Handler');
|
|
|
|
// Filter by multiple trigger types
|
|
const multiTriggerResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(
|
|
'filter={ "triggerNodeTypes": ["n8n-nodes-base.executeWorkflowTrigger", "n8n-nodes-base.scheduleTrigger"] }',
|
|
)
|
|
.expect(200);
|
|
|
|
expect(multiTriggerResponse.body.data).toHaveLength(2);
|
|
const returnedIds = multiTriggerResponse.body.data.map(
|
|
(w: { id: string }) => w.id,
|
|
) as string[];
|
|
expect(returnedIds).toContain(executeWorkflowTriggerWorkflow.id);
|
|
expect(returnedIds).toContain(scheduleTriggerWorkflow.id);
|
|
expect(returnedIds).not.toContain(errorTriggerWorkflow.id);
|
|
|
|
// Filter by non-existent trigger type
|
|
const emptyResponse = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "triggerNodeTypes": ["n8n-nodes-base.nonExistentTrigger"] }')
|
|
.expect(200);
|
|
|
|
expect(emptyResponse.body.data).toHaveLength(0);
|
|
});
|
|
|
|
test('should all workflows when filtering by empty nodeTypes array', async () => {
|
|
await createWorkflow(
|
|
{
|
|
name: 'Test Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "nodeTypes": [] }')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1); // Should return all workflows when nodeTypes is empty
|
|
});
|
|
});
|
|
|
|
describe('select', () => {
|
|
test('should select workflow field: name', async () => {
|
|
await createWorkflow({ name: 'First' }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
const response = await authOwnerAgent.get('/workflows').query('select=["name"]').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{ id: any(String), name: 'First' },
|
|
{ id: any(String), name: 'Second' },
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: active', async () => {
|
|
await createActiveWorkflow({}, owner);
|
|
await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["active"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{ id: any(String), active: true },
|
|
{ id: any(String), active: false },
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: activeVersionId', async () => {
|
|
const activeWorkflow = await createActiveWorkflow({}, owner);
|
|
await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["activeVersionId"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{ id: any(String), activeVersionId: activeWorkflow.versionId },
|
|
{ id: any(String), activeVersionId: null },
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: tags', async () => {
|
|
const firstWorkflow = await createWorkflow({ name: 'First' }, owner);
|
|
const secondWorkflow = await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
await createTag({ name: 'A' }, firstWorkflow);
|
|
await createTag({ name: 'B' }, secondWorkflow);
|
|
|
|
const response = await authOwnerAgent.get('/workflows').query('select=["tags"]').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
objectContaining({ id: any(String), tags: [{ id: any(String), name: 'A' }] }),
|
|
objectContaining({ id: any(String), tags: [{ id: any(String), name: 'B' }] }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow fields: createdAt and updatedAt', async () => {
|
|
const firstWorkflowCreatedAt = '2023-08-08T09:31:25.000Z';
|
|
const firstWorkflowUpdatedAt = '2023-08-08T09:31:40.000Z';
|
|
const secondWorkflowCreatedAt = '2023-07-07T09:31:25.000Z';
|
|
const secondWorkflowUpdatedAt = '2023-07-07T09:31:40.000Z';
|
|
|
|
await createWorkflow(
|
|
{
|
|
createdAt: new Date(firstWorkflowCreatedAt),
|
|
updatedAt: new Date(firstWorkflowUpdatedAt),
|
|
},
|
|
owner,
|
|
);
|
|
await createWorkflow(
|
|
{
|
|
createdAt: new Date(secondWorkflowCreatedAt),
|
|
updatedAt: new Date(secondWorkflowUpdatedAt),
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["createdAt", "updatedAt"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
objectContaining({
|
|
id: any(String),
|
|
createdAt: firstWorkflowCreatedAt,
|
|
updatedAt: firstWorkflowUpdatedAt,
|
|
}),
|
|
objectContaining({
|
|
id: any(String),
|
|
createdAt: secondWorkflowCreatedAt,
|
|
updatedAt: secondWorkflowUpdatedAt,
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: versionId', async () => {
|
|
const firstWorkflowVersionId = 'e95ccdde-2b4e-4fd0-8834-220a2b5b4353';
|
|
const secondWorkflowVersionId = 'd099b8dc-b1d8-4b2d-9b02-26f32c0ee785';
|
|
|
|
await createWorkflow({ versionId: firstWorkflowVersionId }, owner);
|
|
await createWorkflow({ versionId: secondWorkflowVersionId }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["versionId"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{ id: any(String), versionId: firstWorkflowVersionId },
|
|
{ id: any(String), versionId: secondWorkflowVersionId },
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: ownedBy', async () => {
|
|
await createWorkflow({}, owner);
|
|
await createWorkflow({}, owner);
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
|
owner.id,
|
|
);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["ownedBy"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{
|
|
id: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
},
|
|
{
|
|
id: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
},
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should select workflow field: parentFolder', async () => {
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
|
owner.id,
|
|
);
|
|
const folder = await createFolder(ownerPersonalProject, { name: 'Folder 1' });
|
|
|
|
await createWorkflow({ parentFolder: folder }, owner);
|
|
await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('select=["parentFolder"]')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
{
|
|
id: any(String),
|
|
parentFolder: {
|
|
id: folder.id,
|
|
name: folder.name,
|
|
parentFolderId: null,
|
|
},
|
|
},
|
|
{
|
|
id: any(String),
|
|
parentFolder: null,
|
|
},
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sortBy', () => {
|
|
test('should fail when trying to sort by non sortable column', async () => {
|
|
await authOwnerAgent.get('/workflows').query('sortBy=nonSortableColumn:asc').expect(500);
|
|
});
|
|
|
|
test('should sort by createdAt column', async () => {
|
|
await createWorkflow({ name: 'First' }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
let response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=createdAt:asc')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'First' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
]),
|
|
});
|
|
|
|
response = await authOwnerAgent.get('/workflows').query('sortBy=createdAt:desc').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'Second' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should sort by name column', async () => {
|
|
await createWorkflow({ name: 'a' }, owner);
|
|
await createWorkflow({ name: 'b' }, owner);
|
|
await createWorkflow({ name: 'My workflow' }, owner);
|
|
|
|
let response;
|
|
|
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 3,
|
|
data: [
|
|
expect.objectContaining({ name: 'a' }),
|
|
expect.objectContaining({ name: 'b' }),
|
|
expect.objectContaining({ name: 'My workflow' }),
|
|
],
|
|
});
|
|
|
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 3,
|
|
data: [
|
|
expect.objectContaining({ name: 'My workflow' }),
|
|
expect.objectContaining({ name: 'b' }),
|
|
expect.objectContaining({ name: 'a' }),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should sort by updatedAt column', async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 10);
|
|
|
|
await createWorkflow({ name: 'First', updatedAt: futureDate }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
let response;
|
|
|
|
response = await authOwnerAgent.get('/workflows').query('sortBy=updatedAt:asc').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'Second' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
]),
|
|
});
|
|
|
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'First' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
beforeEach(async () => {
|
|
await createWorkflow({ name: 'Workflow 1' }, owner);
|
|
await createWorkflow({ name: 'Workflow 2' }, owner);
|
|
await createWorkflow({ name: 'Workflow 3' }, owner);
|
|
await createWorkflow({ name: 'Workflow 4' }, owner);
|
|
await createWorkflow({ name: 'Workflow 5' }, owner);
|
|
});
|
|
|
|
test('should fail when skip is provided without take', async () => {
|
|
await authOwnerAgent.get('/workflows').query('skip=2').expect(500);
|
|
});
|
|
|
|
test('should handle skip with take parameter', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('skip=2&take=2&sortBy=name:asc')
|
|
.expect(200);
|
|
|
|
const body = response.body as { data: WorkflowEntity[]; count: number };
|
|
expect(body.count).toBe(5);
|
|
expect(body.data).toHaveLength(2);
|
|
const names = body.data.map((item) => item.name);
|
|
expect(names).toEqual(expect.arrayContaining(['Workflow 3', 'Workflow 4']));
|
|
});
|
|
|
|
test('should handle pagination with sorting', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('take=2&skip=1&sortBy=name:desc');
|
|
|
|
const body = response.body as { data: WorkflowEntity[]; count: number };
|
|
expect(body.count).toBe(5);
|
|
expect(body.data).toHaveLength(2);
|
|
const names = body.data.map((item) => item.name);
|
|
expect(names).toEqual(expect.arrayContaining(['Workflow 3', 'Workflow 4']));
|
|
});
|
|
|
|
test('should handle pagination with filtering', async () => {
|
|
// Create additional workflows with specific names for filtering
|
|
await createWorkflow({ name: 'Special Workflow 1' }, owner);
|
|
await createWorkflow({ name: 'Special Workflow 2' }, owner);
|
|
await createWorkflow({ name: 'Special Workflow 3' }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=name:asc&take=2&skip=1')
|
|
.query('filter={"query":"Special"}')
|
|
.expect(200);
|
|
|
|
const body = response.body as { data: WorkflowEntity[]; count: number };
|
|
expect(body.count).toBe(3); // Only 3 'Special' workflows exist
|
|
expect(body.data).toHaveLength(2); // // We skip 1
|
|
const names = body.data.map((item) => item.name);
|
|
expect(names).toEqual(expect.arrayContaining(['Special Workflow 2', 'Special Workflow 3']));
|
|
});
|
|
|
|
test('should return empty array when pagination exceeds total count', async () => {
|
|
const response = await authOwnerAgent.get('/workflows').query('take=2&skip=10').expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(0);
|
|
expect(response.body.count).toBe(5);
|
|
});
|
|
|
|
test('should return all results when no pagination parameters are provided', async () => {
|
|
const response = await authOwnerAgent.get('/workflows').expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(5);
|
|
expect(response.body.count).toBe(5);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows?onlySharedWithMe=true', () => {
|
|
test('should return only workflows shared with me', async () => {
|
|
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
|
member.id,
|
|
);
|
|
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
await createWorkflow({ name: 'First' }, owner);
|
|
await createWorkflow({ name: 'Second' }, member);
|
|
const workflow3 = await createWorkflow({ name: 'Third' }, member);
|
|
|
|
await shareWorkflowWithUsers(workflow3, [owner]);
|
|
|
|
const response = await authOwnerAgent.get('/workflows').query({ onlySharedWithMe: true });
|
|
expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 1,
|
|
data: arrayContaining([
|
|
objectContaining({
|
|
id: any(String),
|
|
name: 'Third',
|
|
active: false,
|
|
activeVersionId: null,
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
versionId: any(String),
|
|
parentFolder: null,
|
|
homeProject: {
|
|
id: memberPersonalProject.id,
|
|
name: member.createPersonalProjectName(),
|
|
icon: null,
|
|
type: memberPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [
|
|
objectContaining({
|
|
id: any(String),
|
|
name: ownerPersonalProject.name,
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows?includeFolders=true', () => {
|
|
test('should return zero workflows and folders if none exist', async () => {
|
|
const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true });
|
|
|
|
expect(response.body).toEqual({ count: 0, data: [] });
|
|
});
|
|
|
|
test('should return workflows and folders', async () => {
|
|
const credential = await saveCredential(randomCredentialPayload(), {
|
|
user: owner,
|
|
role: 'credential:owner',
|
|
});
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const nodes: INode[] = [
|
|
{
|
|
id: uuid(),
|
|
name: 'Action Network',
|
|
type: 'n8n-nodes-base.actionNetwork',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
credentials: {
|
|
actionNetworkApi: {
|
|
id: credential.id,
|
|
name: credential.name,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const tag = await createTag({ name: 'A' });
|
|
|
|
await createWorkflow({ name: 'First', nodes, tags: [tag] }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
await createFolder(ownerPersonalProject, { name: 'Folder' });
|
|
|
|
const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true });
|
|
expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 3,
|
|
data: arrayContaining([
|
|
objectContaining({
|
|
resource: 'workflow',
|
|
id: any(String),
|
|
name: 'First',
|
|
active: false,
|
|
activeVersionId: null,
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
tags: [{ id: any(String), name: 'A' }],
|
|
versionId: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
}),
|
|
objectContaining({
|
|
id: any(String),
|
|
name: 'Second',
|
|
activeVersionId: null,
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
tags: [],
|
|
versionId: any(String),
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
sharedWithProjects: [],
|
|
}),
|
|
objectContaining({
|
|
resource: 'folder',
|
|
id: any(String),
|
|
name: 'Folder',
|
|
createdAt: any(String),
|
|
updatedAt: any(String),
|
|
tags: [],
|
|
homeProject: {
|
|
id: ownerPersonalProject.id,
|
|
name: owner.createPersonalProjectName(),
|
|
icon: null,
|
|
type: ownerPersonalProject.type,
|
|
},
|
|
parentFolder: null,
|
|
workflowCount: 0,
|
|
subFolderCount: 0,
|
|
}),
|
|
]),
|
|
});
|
|
|
|
const found = response.body.data.find(
|
|
(w: ListQueryDb.Workflow.WithOwnership) => w.name === 'First',
|
|
);
|
|
|
|
expect(found.nodes).toBeUndefined();
|
|
expect(found.sharedWithProjects).toHaveLength(0);
|
|
expect(found.usedCredentials).toBeUndefined();
|
|
});
|
|
|
|
test('should NOT returns folders without folder:list scope', async () => {
|
|
const [member1, member2] = await createManyUsers(2, {
|
|
role: { slug: 'global:member' },
|
|
});
|
|
|
|
const teamProject = await createTeamProject(undefined, member1);
|
|
await linkUserToProject(member2, teamProject, folderListMissingRole.slug);
|
|
|
|
const [savedWorkflow1, savedWorkflow2, savedFolder1] = await Promise.all([
|
|
createWorkflow({ name: 'First' }, teamProject),
|
|
createWorkflow({ name: 'Second' }, teamProject),
|
|
createFolder(teamProject, { name: 'Folder' }),
|
|
]);
|
|
|
|
{
|
|
const response = await testServer
|
|
.authAgentFor(member1)
|
|
.get(
|
|
`/workflows?filter={ "projectId": "${teamProject.id}" }&includeScopes=true&includeFolders=true`,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
// project owner
|
|
expect(response.body.data.length).toBe(3);
|
|
|
|
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((wf) => wf.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((wf) => wf.id === savedWorkflow2.id)!;
|
|
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(f1.id).toBe(savedFolder1.id);
|
|
}
|
|
|
|
{
|
|
const response = await testServer
|
|
.authAgentFor(member2)
|
|
.get(
|
|
`/workflows?filter={ "projectId": "${teamProject.id}" }&includeScopes=true&includeFolders=true`,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
// project member
|
|
expect(response.body.data.length).toBe(2);
|
|
|
|
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(f1).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test('should return workflows with scopes and folders when ?includeScopes=true', async () => {
|
|
const [member1, member2] = await createManyUsers(2, {
|
|
role: { slug: 'global:member' },
|
|
});
|
|
|
|
const teamProject = await createTeamProject(undefined, member1);
|
|
await linkUserToProject(member2, teamProject, 'project:editor');
|
|
|
|
const credential = await saveCredential(randomCredentialPayload(), {
|
|
user: owner,
|
|
role: 'credential:owner',
|
|
});
|
|
|
|
const nodes: INode[] = [
|
|
{
|
|
id: uuid(),
|
|
name: 'Action Network',
|
|
type: 'n8n-nodes-base.actionNetwork',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
credentials: {
|
|
actionNetworkApi: {
|
|
id: credential.id,
|
|
name: credential.name,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const tag = await createTag({ name: 'A' });
|
|
|
|
const [savedWorkflow1, savedWorkflow2, savedFolder1] = await Promise.all([
|
|
createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject),
|
|
createWorkflow({ name: 'Second' }, member2),
|
|
createFolder(teamProject, { name: 'Folder' }),
|
|
]);
|
|
|
|
await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]);
|
|
|
|
{
|
|
const response = await testServer
|
|
.authAgentFor(member1)
|
|
.get('/workflows?includeScopes=true&includeFolders=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(3);
|
|
|
|
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((wf) => wf.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((wf) => wf.id === savedWorkflow2.id)!;
|
|
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual(
|
|
[
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:read',
|
|
'workflow:update',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:publish',
|
|
].sort(),
|
|
);
|
|
|
|
expect(f1.id).toBe(savedFolder1.id);
|
|
}
|
|
|
|
{
|
|
const response = await testServer
|
|
.authAgentFor(member2)
|
|
.get('/workflows?includeScopes=true&includeFolders=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(3);
|
|
|
|
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual([
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:update',
|
|
]);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
expect(f1.id).toBe(savedFolder1.id);
|
|
}
|
|
|
|
{
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get('/workflows?includeScopes=true&includeFolders=true');
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body.data.length).toBe(3);
|
|
|
|
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
|
|
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
|
|
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
|
|
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
|
|
|
|
// Team workflow
|
|
expect(wf1.id).toBe(savedWorkflow1.id);
|
|
expect(wf1.scopes).toEqual(
|
|
[
|
|
'workflow:create',
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:list',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
// Shared workflow
|
|
expect(wf2.id).toBe(savedWorkflow2.id);
|
|
expect(wf2.scopes).toEqual(
|
|
[
|
|
'workflow:create',
|
|
'workflow:delete',
|
|
'workflow:execute',
|
|
'workflow:execute-chat',
|
|
'workflow:list',
|
|
'workflow:move',
|
|
'workflow:publish',
|
|
'workflow:read',
|
|
'workflow:share',
|
|
'workflow:update',
|
|
].sort(),
|
|
);
|
|
|
|
expect(f1.id).toBe(savedFolder1.id);
|
|
}
|
|
});
|
|
|
|
describe('filter', () => {
|
|
test('should filter workflows and folders by field: query', async () => {
|
|
const workflow1 = await createWorkflow({ name: 'First' }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
|
|
const ownerProject = await getPersonalProject(owner);
|
|
|
|
const folder1 = await createFolder(ownerProject, { name: 'First' });
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={"query":"First"}&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: [
|
|
objectContaining({ id: folder1.id, name: 'First' }),
|
|
objectContaining({ id: workflow1.id, name: 'First' }),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows and folders by field: active', async () => {
|
|
const workflow1 = await createActiveWorkflow({}, owner);
|
|
await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "active": true }&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 1,
|
|
data: [
|
|
objectContaining({ id: workflow1.id, active: true, versionId: workflow1.versionId }),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows and folders by field: tags (AND operator)', async () => {
|
|
const baseDate = DateTime.now();
|
|
|
|
const workflow1 = await createWorkflow(
|
|
{ name: 'First', updatedAt: baseDate.toJSDate() },
|
|
owner,
|
|
);
|
|
const workflow2 = await createWorkflow(
|
|
{ name: 'Second', updatedAt: baseDate.toJSDate() },
|
|
owner,
|
|
);
|
|
|
|
const ownerProject = await getPersonalProject(owner);
|
|
|
|
const tagA = await createTag(
|
|
{
|
|
name: 'A',
|
|
},
|
|
workflow1,
|
|
);
|
|
const tagB = await createTag(
|
|
{
|
|
name: 'B',
|
|
},
|
|
workflow1,
|
|
);
|
|
|
|
await createTag({ name: 'C' }, workflow2);
|
|
|
|
await createFolder(ownerProject, {
|
|
name: 'First Folder',
|
|
tags: [tagA, tagB],
|
|
updatedAt: baseDate.plus({ minutes: 2 }).toJSDate(),
|
|
});
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "tags": ["A", "B"] }&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 2,
|
|
data: [
|
|
objectContaining({
|
|
name: 'First Folder',
|
|
tags: expect.arrayContaining([
|
|
{ id: any(String), name: 'A' },
|
|
{ id: any(String), name: 'B' },
|
|
]),
|
|
}),
|
|
objectContaining({
|
|
name: 'First',
|
|
tags: expect.arrayContaining([
|
|
{ id: any(String), name: 'A' },
|
|
{ id: any(String), name: 'B' },
|
|
]),
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should filter workflows by projectId', async () => {
|
|
const workflow = await createWorkflow({ name: 'First' }, owner);
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
const folder = await createFolder(pp, {
|
|
name: 'First Folder',
|
|
});
|
|
|
|
const response1 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`)
|
|
.expect(200);
|
|
|
|
expect(response1.body.data).toHaveLength(2);
|
|
expect(response1.body.data[0].id).toBe(folder.id);
|
|
expect(response1.body.data[1].id).toBe(workflow.id);
|
|
|
|
const response2 = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "projectId": "Non-Existing Project ID" }&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response2.body.data).toHaveLength(0);
|
|
});
|
|
|
|
test('should filter workflows by parentFolderId and its descendants when filtering by query', async () => {
|
|
const pp = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
|
|
await createFolder(pp, {
|
|
name: 'Root Folder 1',
|
|
});
|
|
|
|
const rootFolder2 = await createFolder(pp, {
|
|
name: 'Root Folder 2',
|
|
});
|
|
|
|
await createFolder(pp, {
|
|
name: 'Root Folder 3',
|
|
});
|
|
|
|
const subfolder1 = await createFolder(pp, {
|
|
name: 'Root folder 2 subfolder 1 key',
|
|
parentFolder: rootFolder2,
|
|
});
|
|
|
|
await createWorkflow(
|
|
{
|
|
name: 'Workflow 1 key',
|
|
parentFolder: rootFolder2,
|
|
},
|
|
pp,
|
|
);
|
|
|
|
await createWorkflow(
|
|
{
|
|
name: 'workflow 2 key',
|
|
parentFolder: rootFolder2,
|
|
},
|
|
pp,
|
|
);
|
|
|
|
await createWorkflow(
|
|
{
|
|
name: 'workflow 3 key',
|
|
parentFolder: subfolder1,
|
|
},
|
|
pp,
|
|
);
|
|
|
|
const filter2Response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query(
|
|
`filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "query": "key" }&includeFolders=true`,
|
|
);
|
|
|
|
expect(filter2Response.body.count).toBe(4);
|
|
expect(filter2Response.body.data).toHaveLength(4);
|
|
expect(
|
|
filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'workflow'),
|
|
).toHaveLength(3);
|
|
expect(
|
|
filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'folder'),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
test('should return homeProject when filtering workflows and folders by projectId', async () => {
|
|
const workflow = await createWorkflow({ name: 'First' }, member);
|
|
const pp = await getPersonalProject(member);
|
|
const folder = await createFolder(pp, {
|
|
name: 'First Folder',
|
|
});
|
|
|
|
const response = await authMemberAgent
|
|
.get('/workflows')
|
|
.query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(2);
|
|
expect(response.body.data[0].id).toBe(folder.id);
|
|
expect(response.body.data[0].homeProject).not.toBeNull();
|
|
expect(response.body.data[1].id).toBe(workflow.id);
|
|
expect(response.body.data[1].homeProject).not.toBeNull();
|
|
});
|
|
|
|
test('should filter workflows and folders by nodeTypes', async () => {
|
|
const pp = await getPersonalProject(owner);
|
|
|
|
const httpWorkflow = await createWorkflow(
|
|
{
|
|
name: 'HTTP Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
await createWorkflow(
|
|
{
|
|
name: 'Slack Workflow',
|
|
nodes: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
parameters: {},
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
},
|
|
],
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const folder = await createFolder(pp, { name: 'Test Folder' });
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(2); // 1 folder + 1 matching workflow
|
|
const workflowItems = response.body.data.filter((item: any) => item.resource === 'workflow');
|
|
const folderItems = response.body.data.filter((item: any) => item.resource === 'folder');
|
|
|
|
expect(workflowItems).toHaveLength(1);
|
|
expect(workflowItems[0].id).toBe(httpWorkflow.id);
|
|
expect(folderItems).toHaveLength(1);
|
|
expect(folderItems[0].id).toBe(folder.id);
|
|
});
|
|
});
|
|
|
|
describe('sortBy', () => {
|
|
test('should fail when trying to sort by non sortable column', async () => {
|
|
await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=nonSortableColumn:asc&?includeFolders=true')
|
|
.expect(500);
|
|
});
|
|
|
|
test('should sort by createdAt column', async () => {
|
|
await createWorkflow({ name: 'First' }, owner);
|
|
await createWorkflow({ name: 'Second' }, owner);
|
|
const pp = await getPersonalProject(owner);
|
|
await createFolder(pp, {
|
|
name: 'First Folder',
|
|
});
|
|
|
|
await createFolder(pp, {
|
|
name: 'Z Folder',
|
|
});
|
|
|
|
let response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=createdAt:asc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 4,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'First Folder' }),
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
]),
|
|
});
|
|
|
|
response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=createdAt:asc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 4,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'First Folder' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
test('should sort by name column', async () => {
|
|
await createWorkflow({ name: 'a' }, owner);
|
|
await createWorkflow({ name: 'b' }, owner);
|
|
await createWorkflow({ name: 'My workflow' }, owner);
|
|
const pp = await getPersonalProject(owner);
|
|
await createFolder(pp, {
|
|
name: 'a Folder',
|
|
});
|
|
|
|
await createFolder(pp, {
|
|
name: 'Z Folder',
|
|
});
|
|
|
|
let response;
|
|
|
|
response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=name:asc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 5,
|
|
data: [
|
|
expect.objectContaining({ name: 'a Folder' }),
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'a' }),
|
|
expect.objectContaining({ name: 'b' }),
|
|
expect.objectContaining({ name: 'My workflow' }),
|
|
],
|
|
});
|
|
|
|
response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=name:desc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 5,
|
|
data: [
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'a Folder' }),
|
|
expect.objectContaining({ name: 'My workflow' }),
|
|
expect.objectContaining({ name: 'b' }),
|
|
expect.objectContaining({ name: 'a' }),
|
|
],
|
|
});
|
|
});
|
|
|
|
test('should sort by updatedAt column', async () => {
|
|
const baseDate = DateTime.now();
|
|
|
|
const pp = await getPersonalProject(owner);
|
|
await createFolder(pp, {
|
|
name: 'Folder',
|
|
});
|
|
await createFolder(pp, {
|
|
name: 'Z Folder',
|
|
});
|
|
await createWorkflow(
|
|
{ name: 'Second', updatedAt: baseDate.plus({ minutes: 1 }).toJSDate() },
|
|
owner,
|
|
);
|
|
await createWorkflow(
|
|
{ name: 'First', updatedAt: baseDate.plus({ minutes: 2 }).toJSDate() },
|
|
owner,
|
|
);
|
|
|
|
let response;
|
|
|
|
response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=updatedAt:asc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 4,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'Folder' }),
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
]),
|
|
});
|
|
|
|
response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=updatedAt:desc&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
count: 4,
|
|
data: arrayContaining([
|
|
expect.objectContaining({ name: 'Z Folder' }),
|
|
expect.objectContaining({ name: 'Folder' }),
|
|
expect.objectContaining({ name: 'First' }),
|
|
expect.objectContaining({ name: 'Second' }),
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
beforeEach(async () => {
|
|
const pp = await getPersonalProject(owner);
|
|
await createWorkflow({ name: 'Workflow 1' }, owner);
|
|
await createWorkflow({ name: 'Workflow 2' }, owner);
|
|
await createWorkflow({ name: 'Workflow 3' }, owner);
|
|
await createWorkflow({ name: 'Workflow 4' }, owner);
|
|
await createWorkflow({ name: 'Workflow 5' }, owner);
|
|
await createFolder(pp, {
|
|
name: 'Folder 1',
|
|
});
|
|
});
|
|
|
|
test('should fail when skip is provided without take', async () => {
|
|
await authOwnerAgent.get('/workflows?includeFolders=true').query('skip=2').expect(500);
|
|
});
|
|
|
|
test('should handle skip with take parameter', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('skip=2&take=4&includeFolders=true&sortBy=name:asc');
|
|
|
|
const body = response.body as { data: WorkflowEntity[]; count: number };
|
|
expect(body.count).toBe(6);
|
|
expect(body.data).toHaveLength(4);
|
|
const names = body.data.map((item) => item.name);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining(['Workflow 2', 'Workflow 3', 'Workflow 4', 'Workflow 5']),
|
|
);
|
|
});
|
|
|
|
test('should handle pagination with sorting', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('skip=1&take=2&sortBy=name:desc&includeFolders=true');
|
|
|
|
expect(response.body.data).toHaveLength(2);
|
|
expect(response.body.count).toBe(6);
|
|
expect(response.body.data[0].name).toBe('Workflow 5');
|
|
expect(response.body.data[1].name).toBe('Workflow 4');
|
|
});
|
|
|
|
test('should handle pagination with filtering', async () => {
|
|
const pp = await getPersonalProject(owner);
|
|
await createWorkflow({ name: 'Special Workflow 1' }, owner);
|
|
await createWorkflow({ name: 'Special Workflow 2' }, owner);
|
|
await createWorkflow({ name: 'Special Workflow 3' }, owner);
|
|
await createFolder(pp, {
|
|
name: 'Special Folder 1',
|
|
});
|
|
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('sortBy=name:asc&take=2&skip=1')
|
|
.query('filter={"query":"Special"}&includeFolders=true')
|
|
.expect(200);
|
|
|
|
const body = response.body as { data: WorkflowEntity[]; count: number };
|
|
expect(body.count).toBe(4);
|
|
expect(body.data).toHaveLength(2);
|
|
const names = body.data.map((item) => item.name);
|
|
expect(names).toEqual(expect.arrayContaining(['Special Workflow 1', 'Special Workflow 2']));
|
|
});
|
|
|
|
test('should return empty array when pagination exceeds total count', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('take=2&skip=10&includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(0);
|
|
expect(response.body.count).toBe(6);
|
|
});
|
|
|
|
test('should return all results when no pagination parameters are provided', async () => {
|
|
const response = await authOwnerAgent
|
|
.get('/workflows')
|
|
.query('includeFolders=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(6);
|
|
expect(response.body.count).toBe(6);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PATCH /workflows/:workflowId', () => {
|
|
test('should always create workflow history version on nodes and connection changes', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Cron',
|
|
type: 'n8n-nodes-base.cron',
|
|
typeVersion: 1,
|
|
position: [400, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: '{"id":1}',
|
|
settings: {
|
|
saveExecutionProgress: false,
|
|
saveManualExecutions: false,
|
|
saveDataErrorExecution: 'all',
|
|
saveDataSuccessExecution: 'all',
|
|
executionTimeout: 3600,
|
|
timezone: 'America/New_York',
|
|
timeSavedMode: 'fixed',
|
|
timeSavedPerExecution: 10,
|
|
},
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const {
|
|
data: { id, versionId: updatedVersionId },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(id).toBe(workflow.id);
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId: id } });
|
|
expect(versions).toHaveLength(2);
|
|
const newVersion = versions.find((v) => v.versionId === updatedVersionId);
|
|
expect(newVersion).not.toBeNull();
|
|
expect(newVersion!.connections).toEqual(payload.connections);
|
|
expect(newVersion!.nodes).toEqual(payload.nodes);
|
|
});
|
|
|
|
test('should broadcast workflow update to collaborators', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
};
|
|
|
|
await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
|
workflow.id,
|
|
owner.id,
|
|
);
|
|
});
|
|
|
|
test('should not create workflow history version on other changes', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const {
|
|
data: { id },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(id).toBe(workflow.id);
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId: id } });
|
|
expect(versions).toHaveLength(1);
|
|
expect(versions[0].versionId).toBe(workflow.versionId);
|
|
});
|
|
|
|
test('should not create workflow history version if nodes and connections did not change', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
nodes: workflow.nodes,
|
|
connections: workflow.connections,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const {
|
|
data: { id },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(id).toBe(workflow.id);
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId: id } });
|
|
expect(versions).toHaveLength(1);
|
|
expect(versions[0].versionId).toBe(workflow.versionId);
|
|
});
|
|
|
|
test('should set autosaved: true in workflow history when autosaved parameter is sent on update', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
nodes: [
|
|
{
|
|
id: 'uuid-5678',
|
|
parameters: {},
|
|
name: 'Cron',
|
|
type: 'n8n-nodes-base.cron',
|
|
typeVersion: 1,
|
|
position: [400, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
autosaved: true,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id, versionId: updatedVersionId },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId: id } });
|
|
expect(versions).toHaveLength(2);
|
|
const newVersion = versions.find((v) => v.versionId === updatedVersionId);
|
|
expect(newVersion).not.toBeNull();
|
|
expect(newVersion!.autosaved).toBe(true);
|
|
});
|
|
|
|
test('should set autosaved: false in workflow history when autosaved is not sent on update', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
nodes: [
|
|
{
|
|
id: 'uuid-5678',
|
|
parameters: {},
|
|
name: 'Cron',
|
|
type: 'n8n-nodes-base.cron',
|
|
typeVersion: 1,
|
|
position: [400, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const {
|
|
data: { id, versionId: updatedVersionId },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId: id } });
|
|
expect(versions).toHaveLength(2);
|
|
const newVersion = versions.find((v) => v.versionId === updatedVersionId);
|
|
expect(newVersion).not.toBeNull();
|
|
expect(newVersion!.autosaved).toBe(false);
|
|
});
|
|
|
|
test('should ignore provided version id', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const versionId = uuid();
|
|
const payload = {
|
|
description: 'description updated',
|
|
versionId,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const {
|
|
data: { id: workflowId },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(workflowId).toBe(workflow.id);
|
|
const versions = await workflowHistoryRepository.find({ where: { workflowId } });
|
|
expect(versions).toHaveLength(1);
|
|
expect(versions[0].versionId).toBe(workflow.versionId);
|
|
});
|
|
|
|
test('should update the version counter', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
versionId: workflow.versionId,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const {
|
|
data: { id, versionCounter },
|
|
} = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(id).toBe(workflow.id);
|
|
expect(versionCounter).toBe(workflow.versionCounter + 1);
|
|
});
|
|
|
|
test('should update workflow without updating its active version', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
const payload = {
|
|
nodes: [],
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
|
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
|
expect(addRecordSpy).not.toBeCalled();
|
|
|
|
const { data } = response.body as { data: WorkflowEntity };
|
|
expect(data.nodes).toEqual([]);
|
|
expect(data.versionId).not.toBe(workflow.versionId); // New version created
|
|
expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active
|
|
});
|
|
|
|
test('should update workflow meta', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
...workflow,
|
|
meta: {
|
|
templateCredsSetupCompleted: true,
|
|
},
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
const { data: updatedWorkflow } = response.body as { data: WorkflowEntity };
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(updatedWorkflow.id).toBe(workflow.id);
|
|
expect(updatedWorkflow.meta).toEqual(payload.meta);
|
|
});
|
|
|
|
test('should move workflow to folder', async () => {
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
const folder1 = await createFolder(ownerPersonalProject, { name: 'folder1' });
|
|
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
parentFolderId: folder1.id,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneOrFail({
|
|
where: { id: workflow.id },
|
|
relations: ['parentFolder'],
|
|
});
|
|
|
|
expect(updatedWorkflow.parentFolder?.id).toBe(folder1.id);
|
|
});
|
|
|
|
test('should move workflow to project root', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
parentFolderId: PROJECT_ROOT,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneOrFail({
|
|
where: { id: workflow.id },
|
|
relations: ['parentFolder'],
|
|
});
|
|
|
|
expect(updatedWorkflow.parentFolder).toBe(null);
|
|
});
|
|
|
|
test('should fail if an invalid timeSavedMode is provided', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
name: 'name updated',
|
|
versionId: workflow.versionId,
|
|
settings: {
|
|
timeSavedMode: 'invalid' as 'fixed' | 'dynamic',
|
|
},
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body.message).toBe('Invalid timeSavedMode');
|
|
});
|
|
|
|
test('should fail if trying update workflow parent folder with a folder that does not belong to project', async () => {
|
|
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
|
|
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
|
member.id,
|
|
);
|
|
|
|
await createFolder(ownerPersonalProject, { name: 'folder1' });
|
|
const folder2 = await createFolder(memberPersonalProject, { name: 'folder2' });
|
|
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
parentFolderId: folder2.id,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
});
|
|
|
|
test('should not activate when updating with active: true', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
active: true,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
|
|
|
const { data } = response.body as { data: WorkflowEntity };
|
|
expect(data.active).toBe(false);
|
|
expect(data.activeVersionId).toBeNull();
|
|
});
|
|
|
|
test('should not deactivate workflow when updating with active: false', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
active: false,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
|
expect(addRecordSpy).not.toBeCalled();
|
|
|
|
const { data } = response.body as { data: WorkflowEntity };
|
|
expect(data.active).toBe(true);
|
|
expect(data.activeVersionId).toBe(workflow.versionId);
|
|
});
|
|
|
|
test('should not modify activeVersionId when explicitly provided', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
activeVersionId: workflow.versionId,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
|
|
|
const { data } = response.body as { data: WorkflowEntity };
|
|
expect(data.activeVersionId).toBeNull(); // Should not be activated
|
|
});
|
|
|
|
test('should reactivate workflow when settings change', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
|
versionId: workflow.versionId,
|
|
settings: {
|
|
timezone: 'America/New_York',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id);
|
|
expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update');
|
|
});
|
|
|
|
test('should not reactivate when settings unchanged', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
|
versionId: workflow.versionId,
|
|
name: 'New Name',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled();
|
|
expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should not reactivate inactive workflow even when settings change', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
|
versionId: workflow.versionId,
|
|
settings: {
|
|
timezone: 'America/New_York',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled();
|
|
expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should not update an archived workflow', async () => {
|
|
const workflow = await createWorkflowWithHistory({ isArchived: true }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.patch(`/workflows/${workflow.id}`)
|
|
.send({
|
|
name: 'Updated Name',
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toBe('Cannot update an archived workflow.');
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.name).toBe(workflow.name);
|
|
expect(updatedWorkflow!.isArchived).toBe(true);
|
|
});
|
|
|
|
describe('Security: Mass Assignment Protection on Update', () => {
|
|
test.each([
|
|
{
|
|
field: 'triggerCount' as const,
|
|
initialValue: 0,
|
|
maliciousValue: 999,
|
|
description: 'billing bypass',
|
|
assertionType: 'exact' as const,
|
|
},
|
|
{
|
|
field: 'versionCounter' as const,
|
|
initialValue: 1,
|
|
maliciousValue: 999,
|
|
description: 'versioning manipulation',
|
|
assertionType: 'notEqual' as const,
|
|
},
|
|
{
|
|
field: 'isArchived' as const,
|
|
initialValue: false,
|
|
maliciousValue: true,
|
|
description: 'archiving via update',
|
|
assertionType: 'exact' as const,
|
|
},
|
|
])(
|
|
'should prevent modifying $field via API ($description)',
|
|
async ({ field, initialValue, maliciousValue, assertionType }) => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
expect(workflow[field]).toBe(initialValue);
|
|
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
name: 'Updated Name',
|
|
[field]: maliciousValue,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneBy({ id: workflow.id });
|
|
|
|
if (assertionType === 'exact') {
|
|
expect(updatedWorkflow?.[field]).toBe(initialValue);
|
|
} else {
|
|
expect(updatedWorkflow?.[field]).not.toBe(maliciousValue);
|
|
}
|
|
expect(updatedWorkflow?.name).toBe('Updated Name');
|
|
},
|
|
);
|
|
|
|
test('should allow updating legitimate fields while blocking internal fields', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const payload = {
|
|
versionId: workflow.versionId,
|
|
name: 'New Name',
|
|
description: 'New Description',
|
|
nodes: [
|
|
{
|
|
id: 'test-node',
|
|
name: 'Test Node',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [250, 300],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
},
|
|
meta: { updated: true },
|
|
// Attempt to set internal fields
|
|
triggerCount: 999,
|
|
versionCounter: 999,
|
|
isArchived: true,
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneBy({ id: workflow.id });
|
|
|
|
// Legitimate fields should be updated
|
|
expect(updatedWorkflow?.name).toBe('New Name');
|
|
expect(updatedWorkflow?.description).toBe('New Description');
|
|
expect(updatedWorkflow?.nodes).toHaveLength(1);
|
|
expect(updatedWorkflow?.settings).toMatchObject({ saveExecutionProgress: true });
|
|
expect(updatedWorkflow?.meta).toMatchObject({ updated: true });
|
|
|
|
// Internal fields should NOT be modified
|
|
expect(updatedWorkflow?.triggerCount).not.toBe(999);
|
|
expect(updatedWorkflow?.versionCounter).not.toBe(999);
|
|
expect(updatedWorkflow?.isArchived).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('should remove DEFAULT settings from database and keep non-default and not sent values', async () => {
|
|
const workflow = await createWorkflowWithHistory(
|
|
{
|
|
settings: {
|
|
errorWorkflow: 'some-workflow-id',
|
|
timezone: 'America/New_York',
|
|
saveDataErrorExecution: 'all', // should be kept
|
|
executionTimeout: 7200,
|
|
},
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const payload = {
|
|
settings: {
|
|
// These should be removed
|
|
errorWorkflow: 'DEFAULT',
|
|
timezone: 'DEFAULT',
|
|
saveDataSuccessExecution: 'DEFAULT',
|
|
saveManualExecutions: 'DEFAULT',
|
|
saveExecutionProgress: 'DEFAULT',
|
|
// These should be kept (non-default)
|
|
executionTimeout: 7200,
|
|
callerPolicy: 'workflowsFromSameOwner',
|
|
},
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneBy({ id: workflow.id });
|
|
|
|
expect(updatedWorkflow?.settings).toEqual({
|
|
saveDataErrorExecution: 'all',
|
|
executionTimeout: 7200,
|
|
callerPolicy: 'workflowsFromSameOwner',
|
|
});
|
|
});
|
|
|
|
test('should not wipe existing settings when updating workflow without settings field', async () => {
|
|
const workflow = await createWorkflowWithHistory(
|
|
{
|
|
settings: {
|
|
errorWorkflow: 'some-workflow-id',
|
|
timezone: 'America/New_York',
|
|
saveDataErrorExecution: 'all',
|
|
},
|
|
},
|
|
owner,
|
|
);
|
|
|
|
const payload = {
|
|
name: 'Updated Name',
|
|
};
|
|
|
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOneBy({ id: workflow.id });
|
|
|
|
expect(updatedWorkflow?.settings).toEqual({
|
|
errorWorkflow: 'some-workflow-id',
|
|
timezone: 'America/New_York',
|
|
saveDataErrorExecution: 'all',
|
|
});
|
|
expect(updatedWorkflow?.name).toBe('Updated Name');
|
|
});
|
|
});
|
|
|
|
describe('POST /workflows/:workflowId/activate', () => {
|
|
test('should activate workflow with provided versionId', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newVersionId = uuid();
|
|
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: newVersionId });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
|
|
|
const { data } = response.body;
|
|
expect(data.id).toBe(workflow.id);
|
|
expect(data.activeVersionId).toBe(newVersionId);
|
|
expect(data.activeVersion.versionId).toBe(newVersionId);
|
|
expect(data.activeVersion.workflowPublishHistory).toHaveLength(1);
|
|
expect(data.activeVersion.workflowPublishHistory[0]).toMatchObject({
|
|
event: 'activated',
|
|
versionId: newVersionId,
|
|
});
|
|
|
|
expect(addRecordSpy).toBeCalledWith({
|
|
event: 'activated',
|
|
userId: owner.id,
|
|
versionId: newVersionId,
|
|
workflowId: workflow.id,
|
|
});
|
|
});
|
|
|
|
test('should send activated event', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
|
|
const emitSpy = jest.spyOn(eventService, 'emit');
|
|
await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith('workflow-activated', expect.anything());
|
|
});
|
|
|
|
test('should broadcast workflow update to collaborators', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
|
|
await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
|
workflow.id,
|
|
owner.id,
|
|
);
|
|
});
|
|
|
|
test('should return 400 when versionId is missing', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body.path).toContain('versionId');
|
|
expect(response.body.message).toContain('Required');
|
|
});
|
|
|
|
test('should return 404 when workflow does not exist', async () => {
|
|
const response = await authOwnerAgent
|
|
.post('/workflows/non-existent-id/activate')
|
|
.send({ versionId: uuid() });
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
});
|
|
|
|
test('should return 404 if version does not exist', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newVersionId = uuid();
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: newVersionId });
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
expect(response.body.message).toBe('Version not found');
|
|
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
|
});
|
|
|
|
test('should return 403 when user does not have update permission', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authMemberAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
});
|
|
|
|
test('should return 403 when user lacks workflow:publish permission', async () => {
|
|
// Create a custom role with workflow:update but not workflow:publish
|
|
const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], {
|
|
roleType: 'project',
|
|
displayName: 'Custom Workflow Updater',
|
|
description: 'Can update workflows but not publish them',
|
|
});
|
|
|
|
const teamProject = await createTeamProject('Test Project', owner);
|
|
await linkUserToProject(member, teamProject, customRole.slug);
|
|
|
|
const workflow = await createWorkflowWithHistory({}, teamProject);
|
|
|
|
const response = await authMemberAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
|
|
const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } });
|
|
expect(workflowAfter?.active).toBe(false);
|
|
expect(workflowAfter?.activeVersionId).toBeNull();
|
|
});
|
|
|
|
test('should set activeVersion relation when activating', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
|
|
await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
const updatedWorkflow = await workflowRepository.findOne({
|
|
where: { id: workflow.id },
|
|
relations: ['activeVersion'],
|
|
});
|
|
|
|
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
|
|
expect(updatedWorkflow?.activeVersion).not.toBeNull();
|
|
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
|
|
});
|
|
|
|
test('should update version name when provided during activation', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newVersionName = 'Production Version';
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId, name: newVersionName });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
|
|
|
const { data } = response.body;
|
|
expect(data.activeVersionId).toBe(workflow.versionId);
|
|
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
|
});
|
|
|
|
expect(historyVersion?.name).toBe(newVersionName);
|
|
});
|
|
|
|
test('should update version description when provided during activation', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newDescription = 'This is the stable production release';
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId, description: newDescription });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
|
|
|
const { data } = response.body;
|
|
expect(data.activeVersionId).toBe(workflow.versionId);
|
|
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
|
});
|
|
|
|
expect(historyVersion?.description).toBe(newDescription);
|
|
});
|
|
|
|
test('should update both version name and description when provided during activation', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newVersionName = 'Production Version';
|
|
const newDescription = 'Major update with new features';
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
|
versionId: workflow.versionId,
|
|
name: newVersionName,
|
|
description: newDescription,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
|
|
|
const { data } = response.body;
|
|
expect(data.activeVersionId).toBe(workflow.versionId);
|
|
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
|
});
|
|
|
|
expect(historyVersion?.name).toBe(newVersionName);
|
|
expect(historyVersion?.description).toBe(newDescription);
|
|
});
|
|
|
|
test('should not update version name and description when activation fails', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const newVersionName = 'Production Version';
|
|
const newDescription = 'Major update with new features';
|
|
|
|
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed'));
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
|
versionId: workflow.versionId,
|
|
name: newVersionName,
|
|
description: newDescription,
|
|
});
|
|
|
|
const updatedVersion = await workflowHistoryRepository.findOne({
|
|
where: { versionId: workflow.versionId },
|
|
});
|
|
|
|
expect(updatedVersion?.name).toBeNull();
|
|
expect(updatedVersion?.description).toBeNull();
|
|
});
|
|
|
|
test('should return 400 when version name exceeds max length', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const longName = 'a'.repeat(129); // Max is 128
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
|
versionId: workflow.versionId,
|
|
name: longName,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
test('should return 400 when version description exceeds max length', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const longDescription = 'a'.repeat(2049); // Max is 2048
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
|
versionId: workflow.versionId,
|
|
description: longDescription,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
test('should accept version name and description at max length', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const maxName = 'a'.repeat(128);
|
|
const maxDescription = 'b'.repeat(1000);
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
|
versionId: workflow.versionId,
|
|
name: maxName,
|
|
description: maxDescription,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const historyVersion = await workflowHistoryRepository.findOne({
|
|
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
|
});
|
|
|
|
expect(historyVersion?.name).toBe(maxName);
|
|
expect(historyVersion?.description).toBe(maxDescription);
|
|
});
|
|
|
|
test('should deactivate workflow when activation fails', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const newVersionId = uuid();
|
|
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
|
|
|
const emitSpy = jest.spyOn(eventService, 'emit');
|
|
|
|
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Activation failed'));
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: newVersionId });
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body.message).toBe('Activation failed');
|
|
|
|
const updatedWorkflow = await workflowRepository.findOne({
|
|
where: { id: workflow.id },
|
|
relations: ['activeVersion'],
|
|
});
|
|
|
|
// Workflow should be deactivated after failed activation
|
|
expect(updatedWorkflow?.active).toBe(false);
|
|
expect(updatedWorkflow?.activeVersionId).toBeNull();
|
|
|
|
// Should emit deactivation event
|
|
expect(emitSpy).toHaveBeenCalledWith('workflow-deactivated', expect.anything());
|
|
|
|
// Verify workflow was removed once (no re-add)
|
|
expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id);
|
|
expect(activeWorkflowManagerLike.add).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('should call active workflow manager with update mode if workflow is active', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const newVersionId = uuid();
|
|
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
|
|
|
await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: newVersionId });
|
|
|
|
// First remove active version
|
|
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update');
|
|
});
|
|
|
|
test('should call active workflow manager with activate mode if workflow is not active', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
|
|
await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId });
|
|
|
|
expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id);
|
|
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
|
expect(addRecordSpy).toBeCalledWith({
|
|
event: 'activated',
|
|
userId: owner.id,
|
|
versionId: workflow.versionId,
|
|
workflowId: workflow.id,
|
|
});
|
|
});
|
|
|
|
test('should not activate an archived workflow', async () => {
|
|
const workflow = await createWorkflowWithHistory({ isArchived: true }, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: workflow.versionId })
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toBe('Cannot activate an archived workflow.');
|
|
expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled();
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.active).toBe(false);
|
|
expect(updatedWorkflow!.activeVersionId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /workflows/:workflowId/deactivate', () => {
|
|
test('should deactivate active workflow', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
|
|
|
const { data } = response.body;
|
|
expect(data.id).toBe(workflow.id);
|
|
expect(data.active).toBe(false);
|
|
expect(data.activeVersionId).toBeNull();
|
|
expect(addRecordSpy).toBeCalledWith({
|
|
event: 'deactivated',
|
|
userId: owner.id,
|
|
versionId: workflow.versionId,
|
|
workflowId: workflow.id,
|
|
});
|
|
});
|
|
|
|
test('should send deactivated event', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const emitSpy = jest.spyOn(eventService, 'emit');
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith('workflow-deactivated', expect.anything());
|
|
});
|
|
|
|
test('should broadcast workflow update to collaborators', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
|
workflow.id,
|
|
owner.id,
|
|
);
|
|
});
|
|
|
|
test('should handle deactivating already inactive workflow', async () => {
|
|
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
|
expect(addRecordSpy).not.toBeCalled();
|
|
|
|
const { data } = response.body;
|
|
expect(data.activeVersionId).toBeNull();
|
|
});
|
|
|
|
test('should return 404 when workflow does not exist', async () => {
|
|
const response = await authOwnerAgent.post('/workflows/non-existent-id/deactivate');
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
});
|
|
|
|
test('should return 403 when user does not have update permission', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
|
|
const response = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
});
|
|
|
|
test('should return 403 when user lacks workflow:publish permission', async () => {
|
|
// Create a custom role with workflow:update but not workflow:publish
|
|
const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], {
|
|
roleType: 'project',
|
|
displayName: 'Custom Workflow Updater',
|
|
description: 'Can update workflows but not publish them',
|
|
});
|
|
|
|
const teamProject = await createTeamProject('Test Project', owner);
|
|
await linkUserToProject(member, teamProject, customRole.slug);
|
|
|
|
const workflow = await createActiveWorkflow({}, teamProject);
|
|
|
|
const response = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
|
|
const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } });
|
|
expect(workflowAfter?.active).toBe(true);
|
|
expect(workflowAfter?.activeVersionId).toBe(workflow.activeVersionId);
|
|
});
|
|
|
|
test('should clear activeVersion relation when deactivating', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
|
|
|
const updatedWorkflow = await workflowRepository.findOne({
|
|
where: { id: workflow.id },
|
|
relations: ['activeVersion'],
|
|
});
|
|
|
|
expect(updatedWorkflow?.activeVersionId).toBeNull();
|
|
expect(updatedWorkflow?.activeVersion).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /workflows/:workflowId/run', () => {
|
|
let sharingSpy: jest.SpyInstance;
|
|
let tamperingSpy: jest.SpyInstance;
|
|
let workflow: IWorkflowBase;
|
|
|
|
beforeAll(() => {
|
|
const enterpriseWorkflowService = Container.get(EnterpriseWorkflowService);
|
|
|
|
sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled');
|
|
tamperingSpy = jest.spyOn(enterpriseWorkflowService, 'preventTampering');
|
|
workflow = workflowRepository.create({ id: uuid() });
|
|
});
|
|
|
|
test('should prevent tampering if sharing is enabled', async () => {
|
|
sharingSpy.mockReturnValue(true);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow });
|
|
|
|
expect(tamperingSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('should skip tampering prevention if sharing is disabled', async () => {
|
|
sharingSpy.mockReturnValue(false);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow });
|
|
|
|
expect(tamperingSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /workflows/:workflowId/archive', () => {
|
|
test('should archive workflow', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { isArchived, versionId, active },
|
|
} = response.body;
|
|
|
|
expect(isArchived).toBe(true);
|
|
expect(versionId).not.toBe(workflow.versionId);
|
|
expect(active).toBe(false);
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.isArchived).toBe(true);
|
|
});
|
|
|
|
test('should deactivate active workflow on archive', async () => {
|
|
const workflow = await createActiveWorkflow({}, owner);
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { isArchived, versionId, activeVersionId, active },
|
|
} = response.body;
|
|
|
|
expect(isArchived).toBe(true);
|
|
expect(activeVersionId).toBeNull();
|
|
expect(active).toBe(false);
|
|
expect(versionId).not.toBe(workflow.versionId);
|
|
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.isArchived).toBe(true);
|
|
});
|
|
|
|
test('should broadcast workflow update to collaborators', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/archive`).send();
|
|
|
|
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
|
workflow.id,
|
|
owner.id,
|
|
);
|
|
});
|
|
|
|
test('should not archive workflow that is already archived', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, owner);
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toBe('Workflow is already archived.');
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.isArchived).toBe(true);
|
|
});
|
|
|
|
test('should not archive missing workflow', async () => {
|
|
const response = await authOwnerAgent.post('/workflows/404/archive').send().expect(403);
|
|
expect(response.body.message).toBe(
|
|
'Could not archive the workflow - workflow was not found in your projects',
|
|
);
|
|
});
|
|
|
|
test('should not archive a workflow that is not owned by the user', async () => {
|
|
const workflow = await createWorkflow({ isArchived: false }, member);
|
|
|
|
await testServer
|
|
.authAgentFor(anotherMember)
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(403);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).not.toBeNull();
|
|
expect(workflowsInDb!.isArchived).toBe(false);
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test("should allow the owner to archive workflows they don't own", async () => {
|
|
const workflow = await createWorkflow({ isArchived: false }, member);
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { isArchived, versionId },
|
|
} = response.body;
|
|
|
|
expect(isArchived).toBe(true);
|
|
expect(versionId).not.toBe(workflow.versionId);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).not.toBeNull();
|
|
expect(workflowsInDb!.isArchived).toBe(true);
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test('should save workflow history', async () => {
|
|
const workflow = await createWorkflowWithHistory({}, owner);
|
|
const initialVersionId = workflow.versionId;
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/archive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { versionId: newVersionId },
|
|
} = response.body;
|
|
|
|
expect(newVersionId).not.toBe(initialVersionId);
|
|
|
|
const historyRecord = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
workflowId: workflow.id,
|
|
versionId: newVersionId,
|
|
},
|
|
});
|
|
|
|
expect(historyRecord).not.toBeNull();
|
|
expect(historyRecord!.nodes).toEqual(workflow.nodes);
|
|
expect(historyRecord!.connections).toEqual(workflow.connections);
|
|
});
|
|
});
|
|
|
|
describe('POST /workflows/:workflowId/unarchive', () => {
|
|
test('should unarchive workflow', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, owner);
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/unarchive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { isArchived, versionId },
|
|
} = response.body;
|
|
|
|
expect(isArchived).toBe(false);
|
|
expect(versionId).not.toBe(workflow.versionId);
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.isArchived).toBe(false);
|
|
});
|
|
|
|
test('should broadcast workflow update to collaborators', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, owner);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send();
|
|
|
|
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
|
workflow.id,
|
|
owner.id,
|
|
);
|
|
});
|
|
|
|
test('should not unarchive workflow that is already not archived', async () => {
|
|
const workflow = await createWorkflow({ isArchived: false }, owner);
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send().expect(400);
|
|
|
|
const updatedWorkflow = await workflowRepository.findById(workflow.id);
|
|
expect(updatedWorkflow).not.toBeNull();
|
|
expect(updatedWorkflow!.isArchived).toBe(false);
|
|
});
|
|
|
|
test('should not unarchive missing workflow', async () => {
|
|
const response = await authOwnerAgent.post('/workflows/404/unarchive').send().expect(403);
|
|
expect(response.body.message).toBe(
|
|
'Could not unarchive the workflow - workflow was not found in your projects',
|
|
);
|
|
});
|
|
|
|
test('should not unarchive a workflow that is not owned by the user', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, member);
|
|
|
|
await testServer
|
|
.authAgentFor(anotherMember)
|
|
.post(`/workflows/${workflow.id}/unarchive`)
|
|
.send()
|
|
.expect(403);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).not.toBeNull();
|
|
expect(workflowsInDb!.isArchived).toBe(true);
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test("should allow the owner to unarchive workflows they don't own", async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, member);
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/unarchive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { isArchived, versionId },
|
|
} = response.body;
|
|
|
|
expect(isArchived).toBe(false);
|
|
expect(versionId).not.toBe(workflow.versionId);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).not.toBeNull();
|
|
expect(workflowsInDb!.isArchived).toBe(false);
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test('should save workflow history', async () => {
|
|
const workflow = await createWorkflowWithHistory({ isArchived: true }, owner);
|
|
const initialVersionId = workflow.versionId;
|
|
|
|
const response = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/unarchive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const {
|
|
data: { versionId: newVersionId },
|
|
} = response.body;
|
|
|
|
expect(newVersionId).not.toBe(initialVersionId);
|
|
|
|
const historyRecord = await workflowHistoryRepository.findOne({
|
|
where: {
|
|
workflowId: workflow.id,
|
|
versionId: newVersionId,
|
|
},
|
|
});
|
|
|
|
expect(historyRecord).not.toBeNull();
|
|
expect(historyRecord!.nodes).toEqual(workflow.nodes);
|
|
expect(historyRecord!.connections).toEqual(workflow.connections);
|
|
});
|
|
|
|
test('should be able to activate workflow after unarchiving', async () => {
|
|
const workflow = await createWorkflowWithHistory(
|
|
{
|
|
nodes: [
|
|
{
|
|
id: 'trigger-1',
|
|
parameters: {},
|
|
name: 'Schedule Trigger',
|
|
type: 'n8n-nodes-base.scheduleTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
},
|
|
owner,
|
|
);
|
|
|
|
await authOwnerAgent.post(`/workflows/${workflow.id}/archive`).send().expect(200);
|
|
|
|
const unarchiveResponse = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/unarchive`)
|
|
.send()
|
|
.expect(200);
|
|
|
|
const { data: unarchivedWorkflow } = unarchiveResponse.body;
|
|
|
|
const activateResponse = await authOwnerAgent
|
|
.post(`/workflows/${workflow.id}/activate`)
|
|
.send({ versionId: unarchivedWorkflow.versionId })
|
|
.expect(200);
|
|
|
|
expect(activateResponse.body.data.active).toBe(true);
|
|
expect(activateResponse.body.data.activeVersionId).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('DELETE /workflows/:workflowId', () => {
|
|
test('deletes an archived workflow owned by the user', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, owner);
|
|
|
|
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
|
|
const workflowInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowInDb).toBeNull();
|
|
expect(sharedWorkflowsInDb).toHaveLength(0);
|
|
});
|
|
|
|
test('should not delete missing workflow', async () => {
|
|
const response = await authOwnerAgent.delete('/workflows/404').send().expect(403);
|
|
expect(response.body.message).toBe(
|
|
'Could not delete the workflow - workflow was not found in your projects',
|
|
);
|
|
});
|
|
|
|
test('deletes an archived workflow owned by the user, even if the user is just a member', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, member);
|
|
|
|
await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
|
|
const workflowInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowInDb).toBeNull();
|
|
expect(sharedWorkflowsInDb).toHaveLength(0);
|
|
});
|
|
|
|
test('does not delete a workflow that is not archived', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(400);
|
|
expect(response.body.message).toBe('Workflow must be archived before it can be deleted.');
|
|
|
|
const workflowInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowInDb).not.toBeNull();
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test('does not delete an archived workflow that is not owned by the user', async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, member);
|
|
|
|
await testServer
|
|
.authAgentFor(anotherMember)
|
|
.delete(`/workflows/${workflow.id}`)
|
|
.send()
|
|
.expect(403);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).not.toBeNull();
|
|
expect(sharedWorkflowsInDb).toHaveLength(1);
|
|
});
|
|
|
|
test("allows the owner to delete archived workflows they don't own", async () => {
|
|
const workflow = await createWorkflow({ isArchived: true }, member);
|
|
|
|
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
|
|
const workflowsInDb = await workflowRepository.findById(workflow.id);
|
|
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
|
workflowId: workflow.id,
|
|
});
|
|
|
|
expect(workflowsInDb).toBeNull();
|
|
expect(sharedWorkflowsInDb).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('GET /workflows/:workflowId/executions/last-successful', () => {
|
|
test('should return the last successful execution', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const { createSuccessfulExecution } = await import('../shared/db/executions');
|
|
|
|
// Create multiple executions with different statuses
|
|
await createSuccessfulExecution(workflow);
|
|
const lastExecution = await createSuccessfulExecution(workflow);
|
|
|
|
const response = await authOwnerAgent
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toMatchObject({
|
|
id: lastExecution.id,
|
|
workflowId: workflow.id,
|
|
});
|
|
});
|
|
|
|
test('should return 200 with null when no successful execution exists', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await authOwnerAgent
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toBeNull();
|
|
});
|
|
});
|