Files
n8n/packages/cli/test/integration/workflows/workflows.controller.test.ts
Dimitri Lavrenük 8e6d7f45a2 fix: Move webhook duplication checks to backend and make them more reliable (#23094)
Co-authored-by: Michael Siega <michael.siega@n8n.io>
Co-authored-by: Michael Kret <michael.k@radency.com>
2026-01-21 10:49:20 +00:00

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();
});
});