n8n/packages/cli/test/integration/environments/source-control-import.service.test.ts

1936 lines
55 KiB
TypeScript

import type { SourceControlledFile } from '@n8n/api-types';
import {
createTeamProject,
getPersonalProject,
linkUserToProject,
createWorkflowWithHistory,
randomCredentialPayload,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import {
type CredentialsEntity,
CredentialsRepository,
type Folder,
type Project,
type TagEntity,
TagRepository,
type User,
type WorkflowEntity,
WorkflowRepository,
WorkflowTagMappingRepository,
WorkflowHistoryRepository,
} from '@n8n/db';
import {
FolderRepository,
ProjectRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
UserRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import * as fastGlob from 'fast-glob';
import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import type { InstanceSettings } from 'n8n-core';
import * as utils from 'n8n-workflow';
import { nanoid } from 'nanoid';
import fsp from 'node:fs/promises';
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import type { IWorkflowToImport } from '@/interfaces';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { createFolder } from '@test-integration/db/folders';
import { assignTagToWorkflow, createTag } from '@test-integration/db/tags';
import { createCredentials, saveCredential } from '../shared/db/credentials';
import { createAdmin, createMember, createOwner, getGlobalOwner } from '../shared/db/users';
jest.mock('fast-glob');
describe('SourceControlImportService', () => {
let credentialsRepository: CredentialsRepository;
let projectRepository: ProjectRepository;
let sharedCredentialsRepository: SharedCredentialsRepository;
let sharedWorkflowRepository: SharedWorkflowRepository;
let userRepository: UserRepository;
let folderRepository: FolderRepository;
let service: SourceControlImportService;
let workflowRepository: WorkflowRepository;
let tagRepository: TagRepository;
let workflowTagMappingRepository: WorkflowTagMappingRepository;
let workflowHistoryRepository: WorkflowHistoryRepository;
let workflowHistoryService: WorkflowHistoryService;
let sourceControlContextFactory: SourceControlContextFactory;
let sourceControlScopedService: SourceControlScopedService;
const cipher = mockInstance(Cipher);
const mockFileData = new Map<string, string>();
beforeAll(async () => {
await testDb.init();
credentialsRepository = Container.get(CredentialsRepository);
projectRepository = Container.get(ProjectRepository);
sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
userRepository = Container.get(UserRepository);
folderRepository = Container.get(FolderRepository);
workflowRepository = Container.get(WorkflowRepository);
tagRepository = Container.get(TagRepository);
workflowTagMappingRepository = Container.get(WorkflowTagMappingRepository);
workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
workflowHistoryService = Container.get(WorkflowHistoryService);
sourceControlContextFactory = Container.get(SourceControlContextFactory);
sourceControlScopedService = Container.get(SourceControlScopedService);
service = new SourceControlImportService(
mock(),
mock(),
mock(),
credentialsRepository,
projectRepository,
mock(),
tagRepository,
sharedWorkflowRepository,
sharedCredentialsRepository,
userRepository,
mock(),
workflowRepository,
workflowTagMappingRepository,
mock(),
mock(),
mock(),
folderRepository,
mock<InstanceSettings>({ n8nFolder: '/some-path' }),
sourceControlContextFactory,
sourceControlScopedService,
workflowHistoryService,
mock(),
mock(),
mock(),
);
});
afterEach(async () => {
await testDb.truncate([
'WorkflowPublishHistory',
'WorkflowHistory',
'SharedWorkflow',
'WorkflowTagMapping',
'SharedCredentials',
'WorkflowEntity',
'CredentialsEntity',
'TagEntity',
]);
jest.restoreAllMocks();
});
afterAll(async () => {
await testDb.terminate();
});
describe('getRemoteVersionIdsFromFiles()', () => {
const mockWorkflow1File = '/mock/workflow1.json';
const mockWorkflow2File = '/mock/workflow2.json';
const mockWorkflow3File = '/mock/workflow3.json';
const mockWorkflow4File = '/mock/workflow4.json';
const mockWorkflow5File = '/mock/workflow5.json';
const mockWorkflow1Data: Partial<IWorkflowToImport> = {
id: 'workflow1',
versionId: 'v1',
name: 'Test Workflow',
owner: {
type: 'personal',
personalEmail: 'someuser@example.com',
},
};
const mockWorkflow2Data: Partial<IWorkflowToImport> = {
id: 'workflow2',
versionId: 'v1',
name: 'Test Workflow',
owner: {
type: 'team',
teamId: 'team1',
teamName: 'Team 1',
},
};
const mockWorkflow3Data: Partial<IWorkflowToImport> = {
id: 'workflow3',
versionId: 'v1',
name: 'Test Workflow',
owner: {
type: 'team',
teamId: 'team2',
teamName: 'Team 2',
},
};
const mockWorkflow4Data: Partial<IWorkflowToImport> = {
id: 'workflow4',
versionId: 'v1',
name: 'Test Workflow',
owner: {
type: 'personal',
personalEmail: 'someotheruser@example.com',
},
};
const mockWorkflow5Data: Partial<IWorkflowToImport> = {
id: 'workflow5',
versionId: 'v1',
name: 'Test Workflow',
owner: {
type: 'team',
teamId: 'team1',
teamName: 'Team 1',
},
};
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
let globalAdmin: User;
let globalOwner: User;
let globalMember: User;
let teamAdmin: User;
let team1: Project;
beforeEach(async () => {
[globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([
createAdmin(),
createOwner(),
createMember(),
createMember(),
]);
team1 = await createTeamProject('Team 1', teamAdmin);
});
beforeEach(async () => {
globMock.mockImplementation(async () => [
mockWorkflow1File,
mockWorkflow2File,
mockWorkflow3File,
mockWorkflow4File,
mockWorkflow5File,
]);
fsReadFile.mockImplementation(async (path) => {
// Check if this file has mock data in the map (used by some tests)
const pathStr = typeof path === 'string' ? path : path.toString();
if (mockFileData.has(pathStr)) {
return mockFileData.get(pathStr)!;
}
// Otherwise use the predefined mock data
switch (pathStr) {
case mockWorkflow1File:
return JSON.stringify({
...mockWorkflow1Data,
owner: {
type: 'personal',
personalEmail: teamAdmin.email,
},
});
case mockWorkflow2File:
return JSON.stringify({
...mockWorkflow2Data,
owner: {
type: 'team',
teamId: team1.id,
teamName: team1.name,
},
});
case mockWorkflow3File:
return JSON.stringify(mockWorkflow3Data);
case mockWorkflow4File:
return JSON.stringify(mockWorkflow4Data);
case mockWorkflow5File:
return JSON.stringify({
...mockWorkflow5Data,
owner: {
type: 'team',
teamId: team1.id,
teamName: team1.name,
},
});
}
throw new Error(`Trying to access invalid file in test: ${pathStr}`);
});
});
it('should show all remote workflows for instance admins', async () => {
const result = await service.getRemoteVersionIdsFromFiles(
await sourceControlContextFactory.createContext(globalAdmin),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set(
[
mockWorkflow1Data,
mockWorkflow2Data,
mockWorkflow3Data,
mockWorkflow4Data,
mockWorkflow5Data,
].map((r) => r.id),
),
);
});
it('should show all remote workflows for instance owners', async () => {
const result = await service.getRemoteVersionIdsFromFiles(
await sourceControlContextFactory.createContext(globalOwner),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set(
[
mockWorkflow1Data,
mockWorkflow2Data,
mockWorkflow3Data,
mockWorkflow4Data,
mockWorkflow5Data,
].map((r) => r.id),
),
);
});
it('should return no remote workflows for instance members', async () => {
const result = await service.getRemoteVersionIdsFromFiles(
await sourceControlContextFactory.createContext(globalMember),
);
expect(result).toBeEmptyArray();
});
it('should return only remote workflows that belong to team project', async () => {
const result = await service.getRemoteVersionIdsFromFiles(
await sourceControlContextFactory.createContext(teamAdmin),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set([mockWorkflow2Data, mockWorkflow5Data].map((r) => r.id)),
);
});
});
describe('getLocalVersionIdsFromDb()', () => {
let instanceOwner: User;
let projectAdmin: User;
let projectMember: User;
let teamProjectA: Project;
let teamProjectB: Project;
let teamAWorkflows: WorkflowEntity[];
let teamBWorkflows: WorkflowEntity[];
let instanceOwnerWorkflows: WorkflowEntity[];
let projectAdminWorkflows: WorkflowEntity[];
let projectMemberWorkflows: WorkflowEntity[];
beforeEach(async () => {
[instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([
getGlobalOwner(),
createMember(),
createMember(),
createTeamProject(),
createTeamProject(),
]);
await linkUserToProject(projectAdmin, teamProjectA, 'project:admin');
await linkUserToProject(projectMember, teamProjectA, 'project:editor');
await linkUserToProject(projectAdmin, teamProjectB, 'project:editor');
await linkUserToProject(projectMember, teamProjectB, 'project:editor');
teamAWorkflows = await Promise.all([
await createWorkflowWithHistory({}, teamProjectA),
await createWorkflowWithHistory({}, teamProjectA),
await createWorkflowWithHistory({}, teamProjectA),
]);
teamBWorkflows = await Promise.all([
await createWorkflowWithHistory({}, teamProjectB),
await createWorkflowWithHistory({}, teamProjectB),
await createWorkflowWithHistory({}, teamProjectB),
]);
instanceOwnerWorkflows = await Promise.all([
await createWorkflowWithHistory({}, instanceOwner),
await createWorkflowWithHistory({}, instanceOwner),
await createWorkflowWithHistory({}, instanceOwner),
]);
projectAdminWorkflows = await Promise.all([
await createWorkflowWithHistory({}, projectAdmin),
await createWorkflowWithHistory({}, projectAdmin),
await createWorkflowWithHistory({}, projectAdmin),
]);
projectMemberWorkflows = await Promise.all([
await createWorkflowWithHistory({}, projectMember),
await createWorkflowWithHistory({}, projectMember),
await createWorkflowWithHistory({}, projectMember),
]);
});
describe('if user is an instance owner', () => {
it('should get all available workflows on the instance', async () => {
const versions = await service.getLocalVersionIdsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
expect(new Set(versions.map((v) => v.id))).toEqual(
new Set([
...teamAWorkflows.map((w) => w.id),
...teamBWorkflows.map((w) => w.id),
...instanceOwnerWorkflows.map((w) => w.id),
...projectAdminWorkflows.map((w) => w.id),
...projectMemberWorkflows.map((w) => w.id),
]),
);
});
});
describe('if user is a project admin of a team project', () => {
it('should only get all available workflows from the team project', async () => {
const versions = await service.getLocalVersionIdsFromDb(
await sourceControlContextFactory.createContext(projectAdmin),
);
expect(new Set(versions.map((v) => v.id))).toEqual(
new Set([...teamAWorkflows.map((w) => w.id)]),
);
});
});
describe('if user is a project member of a team project', () => {
it('should not get any workflows', async () => {
const versions = await service.getLocalVersionIdsFromDb(
await sourceControlContextFactory.createContext(projectMember),
);
expect(versions).toBeEmptyArray();
});
});
});
describe('getRemoteCredentialsFromFiles()', () => {
const mockCredential1File = '/mock/credential1.json';
const mockCredential2File = '/mock/credential2.json';
const mockCredential3File = '/mock/credential3.json';
const mockCredential4File = '/mock/credential4.json';
const mockCredential5File = '/mock/credential5.json';
const mockCredential1Data: Partial<ExportableCredential> = {
id: 'credentials1',
name: 'Test Workflow',
ownedBy: {
type: 'personal',
personalEmail: 'someuser@example.com',
},
};
const mockCredential2Data: Partial<ExportableCredential> = {
id: 'credentials2',
name: 'Test Workflow',
ownedBy: {
type: 'team',
teamId: 'team1',
teamName: 'Team 1',
},
};
const mockCredential3Data: Partial<ExportableCredential> = {
id: 'credentials3',
name: 'Test Workflow',
ownedBy: {
type: 'team',
teamId: 'team2',
teamName: 'Team 2',
},
};
const mockCredential4Data: Partial<ExportableCredential> = {
id: 'credentials4',
name: 'Test Workflow',
ownedBy: {
type: 'personal',
personalEmail: 'someotheruser@example.com',
},
};
const mockCredential5Data: Partial<ExportableCredential> = {
id: 'credentials5',
name: 'Test Workflow',
ownedBy: {
type: 'team',
teamId: 'team1',
teamName: 'Team 1',
},
};
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
let globalAdmin: User;
let globalOwner: User;
let globalMember: User;
let teamAdmin: User;
let team1: Project;
beforeEach(async () => {
[globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([
createAdmin(),
createOwner(),
createMember(),
createMember(),
]);
team1 = await createTeamProject('Team 1', teamAdmin);
});
beforeEach(async () => {
globMock.mockImplementation(async () => [
mockCredential1File,
mockCredential2File,
mockCredential3File,
mockCredential4File,
mockCredential5File,
]);
fsReadFile.mockImplementation(async (path) => {
switch (path) {
case mockCredential1File:
return JSON.stringify({
...mockCredential1Data,
ownedBy: {
type: 'personal',
personalEmail: teamAdmin.email,
},
});
case mockCredential2File:
return JSON.stringify({
...mockCredential2Data,
ownedBy: {
type: 'team',
teamId: team1.id,
teamName: team1.name,
},
});
case mockCredential3File:
return JSON.stringify(mockCredential3Data);
case mockCredential4File:
return JSON.stringify(mockCredential4Data);
case mockCredential5File:
return JSON.stringify({
...mockCredential5Data,
ownedBy: {
type: 'team',
teamId: team1.id,
teamName: team1.name,
},
});
}
throw new Error(`Trying to access invalid file in test: ${path}`);
});
});
it('should show all remote credentials for instance admins', async () => {
const result = await service.getRemoteCredentialsFromFiles(
await sourceControlContextFactory.createContext(globalAdmin),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set(
[
mockCredential1Data,
mockCredential2Data,
mockCredential3Data,
mockCredential4Data,
mockCredential5Data,
].map((r) => r.id),
),
);
});
it('should show all remote credentials for instance owners', async () => {
const result = await service.getRemoteCredentialsFromFiles(
await sourceControlContextFactory.createContext(globalOwner),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set(
[
mockCredential1Data,
mockCredential2Data,
mockCredential3Data,
mockCredential4Data,
mockCredential5Data,
].map((r) => r.id),
),
);
});
it('should return no remote credentials for instance members', async () => {
const result = await service.getRemoteCredentialsFromFiles(
await sourceControlContextFactory.createContext(globalMember),
);
expect(result).toBeEmptyArray();
});
it('should return only remote credentials that belong to team project', async () => {
const result = await service.getRemoteCredentialsFromFiles(
await sourceControlContextFactory.createContext(teamAdmin),
);
expect(new Set(result.map((r) => r.id))).toEqual(
new Set([mockCredential2Data, mockCredential5Data].map((r) => r.id)),
);
});
});
describe('getLocalCredentialsFromDb', () => {
let instanceOwner: User;
let projectAdmin: User;
let projectMember: User;
let teamProjectA: Project;
let teamProjectB: Project;
let teamACredentials: CredentialsEntity[];
let teamBCredentials: CredentialsEntity[];
beforeEach(async () => {
[instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([
getGlobalOwner(),
createMember(),
createMember(),
createTeamProject(),
createTeamProject(),
]);
await linkUserToProject(projectAdmin, teamProjectA, 'project:admin');
await linkUserToProject(projectMember, teamProjectA, 'project:editor');
await linkUserToProject(projectAdmin, teamProjectB, 'project:editor');
await linkUserToProject(projectMember, teamProjectB, 'project:editor');
teamACredentials = await Promise.all([
await createCredentials(
{
name: 'credential1',
data: '',
type: 'test',
},
teamProjectA,
),
await createCredentials(
{
name: 'credential2',
data: '',
type: 'test',
},
teamProjectA,
),
await createCredentials(
{
name: 'credential3',
data: '',
type: 'test',
},
teamProjectA,
),
]);
teamBCredentials = await Promise.all([
await createCredentials(
{
name: 'credential4',
data: '',
type: 'test',
},
teamProjectB,
),
await createCredentials(
{
name: 'credential5',
data: '',
type: 'test',
},
teamProjectB,
),
await createCredentials(
{
name: 'credential6',
data: '',
type: 'test',
},
teamProjectB,
),
]);
});
it('should get all available credentials on the instance, for an instance owner', async () => {
const versions = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
expect(new Set(versions.map((v) => v.id))).toEqual(
new Set([...teamACredentials.map((w) => w.id), ...teamBCredentials.map((w) => w.id)]),
);
});
it('should only get all available credentials from the team project, for a project admin', async () => {
const versions = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(projectAdmin),
);
expect(new Set(versions.map((v) => v.id))).toEqual(
new Set([...teamACredentials.map((w) => w.id)]),
);
});
it('should not get any workflows, for a project member', async () => {
const versions = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(projectMember),
);
expect(versions).toBeEmptyArray();
});
it('should include isGlobal flag in returned credentials', async () => {
// Create a global credential
const globalCredential = await createCredentials(
{
name: 'global-credential',
data: '',
type: 'test',
isGlobal: true,
},
teamProjectA,
);
// Create a non-global credential
const nonGlobalCredential = await createCredentials(
{
name: 'non-global-credential',
data: '',
type: 'test',
isGlobal: false,
},
teamProjectA,
);
const credentials = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
const globalCred = credentials.find((c) => c.id === globalCredential.id);
const nonGlobalCred = credentials.find((c) => c.id === nonGlobalCredential.id);
expect(globalCred).toBeDefined();
expect(globalCred?.isGlobal).toBe(true);
expect(nonGlobalCred).toBeDefined();
expect(nonGlobalCred?.isGlobal).toBe(false);
});
it('should default isGlobal to false when not specified', async () => {
// Create a credential without specifying isGlobal (should default to false)
const credential = await createCredentials(
{
name: 'credential-without-flag',
data: '',
type: 'test',
},
teamProjectA,
);
const credentials = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
const cred = credentials.find((c) => c.id === credential.id);
expect(cred).toBeDefined();
expect(cred?.isGlobal).toBe(false);
});
it('should include required properties in returned credentials', async () => {
const credentials = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
expect(credentials.length).toBeGreaterThan(0);
credentials.forEach((credential) => {
expect(credential).toHaveProperty('id');
expect(credential).toHaveProperty('name');
expect(credential).toHaveProperty('type');
expect(credential).toHaveProperty('data');
expect(credential).toHaveProperty('filename');
expect(credential).toHaveProperty('isGlobal');
expect(typeof credential.isGlobal).toBe('boolean');
});
});
});
describe('getLocalFoldersAndMappingsFromDb()', () => {
let instanceOwner: User;
let projectAdmin: User;
let projectMember: User;
let teamProjectA: Project;
let teamProjectB: Project;
let foldersProjectA: Folder[];
let foldersProjectB: Folder[];
beforeEach(async () => {
[instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([
getGlobalOwner(),
createMember(),
createMember(),
createTeamProject(),
createTeamProject(),
]);
await linkUserToProject(projectAdmin, teamProjectA, 'project:admin');
await linkUserToProject(projectMember, teamProjectA, 'project:editor');
await linkUserToProject(projectAdmin, teamProjectB, 'project:editor');
await linkUserToProject(projectMember, teamProjectB, 'project:editor');
foldersProjectA = await Promise.all([
await createFolder(teamProjectA, {
name: 'folder1',
}),
await createFolder(teamProjectA, {
name: 'folder2',
}),
await createFolder(teamProjectA, {
name: 'folder3',
}),
]);
foldersProjectA.push(
await createFolder(teamProjectA, {
name: 'folder1.1',
parentFolder: foldersProjectA[0],
}),
);
foldersProjectB = await Promise.all([
await createFolder(teamProjectB, {
name: 'folder1',
}),
await createFolder(teamProjectB, {
name: 'folder2',
}),
await createFolder(teamProjectB, {
name: 'folder3',
}),
]);
});
it('should get all available folders on the instance, for an instance owner', async () => {
const folders = await service.getLocalFoldersAndMappingsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
expect(new Set(folders.folders.map((v) => v.id))).toEqual(
new Set([...foldersProjectA.map((w) => w.id), ...foldersProjectB.map((w) => w.id)]),
);
});
it('should only get all available folders from the team project, for a project admin', async () => {
const versions = await service.getLocalFoldersAndMappingsFromDb(
await sourceControlContextFactory.createContext(projectAdmin),
);
expect(new Set(versions.folders.map((v) => v.id))).toEqual(
new Set([...foldersProjectA.map((w) => w.id)]),
);
});
it('should not get any folders, for a project member', async () => {
const versions = await service.getLocalFoldersAndMappingsFromDb(
await sourceControlContextFactory.createContext(projectMember),
);
expect(versions.folders).toBeEmptyArray();
});
});
describe('getRemoteTagsAndMappingsFromFile()', () => {
const mockTagsFile = '/mock/tags.json';
const mockTagData: {
tags: Array<{ id: string; name: string }>;
mappings: Array<{ workflowId: string; tagId: string }>;
} = {
tags: [
{
id: 'tag1',
name: 'Tag 1',
},
{
id: 'tag2',
name: 'Tag 2',
},
{
id: 'tag3',
name: 'Tag 3',
},
],
mappings: [
{
tagId: 'tag1',
workflowId: 'wf1',
},
{
tagId: 'tag2',
workflowId: 'wf2',
},
{
tagId: 'tag3',
workflowId: 'wf3',
},
{
tagId: 'tag1',
workflowId: 'wf4',
},
{
tagId: 'tag2',
workflowId: 'wf5',
},
],
};
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
let globalAdmin: User;
let globalOwner: User;
let globalMember: User;
let teamAdmin: User;
let team1: Project;
let team2: Project;
let workflowTeam1: WorkflowEntity[];
beforeEach(async () => {
[globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([
createAdmin(),
createOwner(),
createMember(),
createMember(),
]);
globMock.mockResolvedValue([mockTagsFile]);
fsReadFile.mockResolvedValue(JSON.stringify(mockTagData));
[team1, team2] = await Promise.all([
await createTeamProject('Team 1', teamAdmin),
await createTeamProject('Team 2'),
]);
workflowTeam1 = await Promise.all([
await createWorkflowWithHistory(
{
id: 'wf1',
name: 'Workflow 1',
},
team1,
),
await createWorkflowWithHistory(
{
id: 'wf2',
name: 'Workflow 2',
},
team1,
),
await createWorkflowWithHistory(
{
id: 'wf3',
name: 'Workflow 3',
},
team1,
),
]);
await Promise.all([
await createWorkflowWithHistory(
{
id: 'wf4',
name: 'Workflow 4',
},
team2,
),
await createWorkflowWithHistory(
{
id: 'wf5',
name: 'Workflow 5',
},
team2,
),
await createWorkflowWithHistory(
{
id: 'wf6',
name: 'Workflow 6',
},
team2,
),
]);
});
it('should show all remote tags and all remote mappings for instance admins', async () => {
const result = await service.getRemoteTagsAndMappingsFromFile(
await sourceControlContextFactory.createContext(globalAdmin),
);
expect(new Set(result.tags.map((r) => r.id))).toEqual(
new Set(mockTagData.tags.map((t) => t.id)),
);
expect(new Set(result.mappings)).toEqual(new Set(mockTagData.mappings));
});
it('should show all remote tags and all remote mappings for instance owners', async () => {
const result = await service.getRemoteTagsAndMappingsFromFile(
await sourceControlContextFactory.createContext(globalOwner),
);
expect(new Set(result.tags.map((r) => r.id))).toEqual(
new Set(mockTagData.tags.map((t) => t.id)),
);
expect(new Set(result.mappings)).toEqual(new Set(mockTagData.mappings));
});
it('should return all remote tags and no remote mappings for instance members', async () => {
const result = await service.getRemoteTagsAndMappingsFromFile(
await sourceControlContextFactory.createContext(globalMember),
);
expect(new Set(result.tags.map((r) => r.id))).toEqual(
new Set(mockTagData.tags.map((t) => t.id)),
);
expect(result.mappings).toBeEmptyArray();
});
it('should return all remote tags and only remote mappings for in scope team for team admin', async () => {
const result = await service.getRemoteTagsAndMappingsFromFile(
await sourceControlContextFactory.createContext(teamAdmin),
);
expect(new Set(result.tags.map((r) => r.id))).toEqual(
new Set(mockTagData.tags.map((t) => t.id)),
);
expect(new Set(result.mappings)).toEqual(
new Set(
mockTagData.mappings.filter((mapping) =>
workflowTeam1.some((wf) => wf.id === mapping.workflowId),
),
),
);
});
});
describe('getLocalTagsAndMappingsFromDb()', () => {
let instanceOwner: User;
let projectAdmin: User;
let projectMember: User;
let teamProjectA: Project;
let teamProjectB: Project;
let tags: TagEntity[];
let workflowsProjectA: WorkflowEntity[];
let workflowsProjectB: WorkflowEntity[];
let mappings: Array<[TagEntity, WorkflowEntity]>;
beforeEach(async () => {
[instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([
getGlobalOwner(),
createMember(),
createMember(),
createTeamProject(),
createTeamProject(),
]);
await linkUserToProject(projectAdmin, teamProjectA, 'project:admin');
await linkUserToProject(projectMember, teamProjectA, 'project:editor');
await linkUserToProject(projectAdmin, teamProjectB, 'project:editor');
await linkUserToProject(projectMember, teamProjectB, 'project:editor');
tags = await Promise.all([
await createTag({
name: 'tag1',
}),
await createTag({
name: 'tag2',
}),
await createTag({
name: 'tag3',
}),
]);
workflowsProjectA = await Promise.all([
await createWorkflowWithHistory(
{
id: 'workflow1',
name: 'Workflow 1',
},
teamProjectA,
),
await createWorkflowWithHistory(
{
id: 'workflow2',
name: 'Workflow 2',
},
teamProjectA,
),
await createWorkflowWithHistory(
{
id: 'workflow3',
name: 'Workflow 3',
},
teamProjectA,
),
]);
workflowsProjectB = await Promise.all([
await createWorkflowWithHistory(
{
id: 'workflow4',
name: 'Workflow 4',
},
teamProjectB,
),
await createWorkflowWithHistory(
{
id: 'workflow5',
name: 'Workflow 5',
},
teamProjectB,
),
await createWorkflowWithHistory(
{
id: 'workflow6',
name: 'Workflow 6',
},
teamProjectB,
),
]);
mappings = [
[tags[0], workflowsProjectA[0]],
[tags[1], workflowsProjectA[0]],
[tags[0], workflowsProjectA[1]],
[tags[0], workflowsProjectB[0]],
[tags[1], workflowsProjectB[1]],
[tags[2], workflowsProjectB[2]],
];
await Promise.all(
mappings.map(async ([tag, workflow]) => await assignTagToWorkflow(tag, workflow)),
);
});
it('should get all available tags and mappings on the instance, for an instance owner', async () => {
const result = await service.getLocalTagsAndMappingsFromDb(
await sourceControlContextFactory.createContext(instanceOwner),
);
expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)]));
expect(
new Set(
result.mappings.map((m) => {
return [m.tagId, m.workflowId];
}),
),
).toEqual(
new Set(
mappings.map(([tag, workflow]) => {
return [tag.id, workflow.id];
}),
),
);
});
it('should only get all available tags and only mappings from the team project, for a project admin', async () => {
const result = await service.getLocalTagsAndMappingsFromDb(
await sourceControlContextFactory.createContext(projectAdmin),
);
expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)]));
expect(
new Set(
result.mappings.map((m) => {
return [m.tagId, m.workflowId];
}),
),
).toEqual(
new Set(
mappings
.filter((w) => workflowsProjectA.includes(w[1]))
.map(([tag, workflow]) => {
return [tag.id, workflow.id];
}),
),
);
});
it('should get all available tags but no mappings, for a project member', async () => {
const result = await service.getLocalTagsAndMappingsFromDb(
await sourceControlContextFactory.createContext(projectMember),
);
expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)]));
expect(result.mappings).toBeEmptyArray();
});
});
describe('importTagsFromWorkFolder()', () => {
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
const mockTagsFile = '/mock/tags.json';
const standardTags = [
{ id: 'tag-1', name: 'Tag 1' },
{ id: 'tag-2', name: 'Tag 2' },
];
function setupTagsFileMock(mappings: Array<{ tagId: string; workflowId: string }>): void {
globMock.mockResolvedValue([mockTagsFile]);
fsReadFile.mockResolvedValue(JSON.stringify({ tags: standardTags, mappings }));
}
function createTagsCandidate(): ReturnType<typeof mock<SourceControlledFile>> {
return mock<SourceControlledFile>({ id: 'tags', file: mockTagsFile, type: 'tags' });
}
async function getMappingsForWorkflow(workflowId: string) {
return await workflowTagMappingRepository.findBy({ workflowId });
}
it('should remove tags from workflows when they are removed in source control', async () => {
const user = await getGlobalOwner();
const teamProject = await createTeamProject('Team 1');
const workflow1 = await createWorkflowWithHistory(
{ id: 'workflow-1', name: 'Workflow 1' },
teamProject,
);
const [tag1, tag2] = await Promise.all([
createTag({ id: 'tag-1', name: 'Tag 1' }),
createTag({ id: 'tag-2', name: 'Tag 2' }),
]);
await assignTagToWorkflow(tag1, workflow1);
await assignTagToWorkflow(tag2, workflow1);
expect(await getMappingsForWorkflow(workflow1.id)).toHaveLength(2);
setupTagsFileMock([{ tagId: 'tag-1', workflowId: 'workflow-1' }]);
await service.importTagsFromWorkFolder(createTagsCandidate(), user);
const finalMappings = await getMappingsForWorkflow(workflow1.id);
expect(finalMappings).toHaveLength(1);
expect(finalMappings[0].tagId).toBe('tag-1');
const tagsInDb = await tagRepository.find();
expect(tagsInDb).toHaveLength(2);
});
it('should only delete mappings for workflows present in the imported data', async () => {
const user = await getGlobalOwner();
const teamProject = await createTeamProject('Team 1');
const [workflow1, workflow2] = await Promise.all([
createWorkflowWithHistory({ id: 'workflow-1', name: 'Workflow 1' }, teamProject),
createWorkflowWithHistory({ id: 'workflow-2', name: 'Workflow 2' }, teamProject),
]);
const [tag1, tag2] = await Promise.all([
createTag({ id: 'tag-1', name: 'Tag 1' }),
createTag({ id: 'tag-2', name: 'Tag 2' }),
]);
await assignTagToWorkflow(tag1, workflow1);
await assignTagToWorkflow(tag2, workflow1);
await assignTagToWorkflow(tag1, workflow2);
setupTagsFileMock([{ tagId: 'tag-1', workflowId: 'workflow-1' }]);
await service.importTagsFromWorkFolder(createTagsCandidate(), user);
const workflow1Mappings = await getMappingsForWorkflow(workflow1.id);
expect(workflow1Mappings).toHaveLength(1);
expect(workflow1Mappings[0].tagId).toBe('tag-1');
const workflow2Mappings = await getMappingsForWorkflow(workflow2.id);
expect(workflow2Mappings).toHaveLength(1);
expect(workflow2Mappings[0].tagId).toBe('tag-1');
});
it('should remove all tags from a workflow when it ends up with zero tags', async () => {
const user = await getGlobalOwner();
const teamProject = await createTeamProject('Team 1');
const workflow1 = await createWorkflowWithHistory(
{ id: 'workflow-1', name: 'Workflow 1' },
teamProject,
);
const [tag1, tag2] = await Promise.all([
createTag({ id: 'tag-1', name: 'Tag 1' }),
createTag({ id: 'tag-2', name: 'Tag 2' }),
]);
await assignTagToWorkflow(tag1, workflow1);
await assignTagToWorkflow(tag2, workflow1);
expect(await getMappingsForWorkflow(workflow1.id)).toHaveLength(2);
// First, the tags file is read
fsReadFile.mockResolvedValueOnce(JSON.stringify({ tags: standardTags, mappings: [] }));
// Then workflow files are read via getRemoteVersionIdsFromFiles
const mockWorkflowFile = '/mock/workflows/workflow-1.json';
globMock.mockResolvedValueOnce([mockWorkflowFile]);
fsReadFile.mockResolvedValueOnce(
JSON.stringify({
id: 'workflow-1',
name: 'Workflow 1',
nodes: [],
connections: {},
versionId: '1',
}),
);
await service.importTagsFromWorkFolder(createTagsCandidate(), user);
// All mappings should be removed
const finalMappings = await getMappingsForWorkflow(workflow1.id);
expect(finalMappings).toHaveLength(0);
// Tags themselves should still exist
const tagsInDb = await tagRepository.find();
expect(tagsInDb).toHaveLength(2);
});
});
describe('importCredentialsFromWorkFolder()', () => {
describe('if user email specified by `ownedBy` exists at target instance', () => {
it('should assign credential ownership to original user', async () => {
const [importingUser, member] = await Promise.all([getGlobalOwner(), createMember()]);
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: member.email, // user at source instance owns credential
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const personalProject = await getPersonalProject(member);
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
});
expect(sharing).toBeTruthy(); // same user at target instance owns credential
});
});
describe('if user email specified by `ownedBy` is `null`', () => {
it('should assign credential ownership to importing user', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: null,
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const personalProject = await getPersonalProject(importingUser);
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
});
expect(sharing).toBeTruthy(); // original user has no email, so importing user owns credential
});
});
describe('if user email specified by `ownedBy` does not exist at target instance', () => {
it('should assign credential ownership to importing user', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: 'user@test.com', // user at source instance owns credential
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const personalProject = await getPersonalProject(importingUser);
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
});
expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential
});
});
});
describe('if owner specified by `ownedBy` does not exist at target instance', () => {
it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: {
type: 'personal',
personalEmail: 'test@example.com',
}, // user at source instance owns credential
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const personalProject = await getPersonalProject(importingUser);
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
});
expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential
});
it('should create a new team project if the credential was owned by a team project in the source instance', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: {
type: 'team',
teamId: '1234-asdf',
teamName: 'Marketing',
}, // user at source instance owns credential
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
{
const project = await projectRepository.findOne({
where: [
{
id: '1234-asdf',
},
{ name: 'Marketing' },
],
});
expect(project?.id).not.toBe('1234-asdf');
expect(project?.name).not.toBe('Marketing');
}
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const sharing = await sharedCredentialsRepository.findOne({
where: {
credentialsId: CREDENTIAL_ID,
role: 'credential:owner',
},
relations: { project: true },
});
expect(sharing?.project.id).toBe('1234-asdf');
expect(sharing?.project.name).toBe('Marketing');
expect(sharing?.project.type).toBe('team');
expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential
});
});
describe('if owner specified by `ownedBy` does exist at target instance', () => {
it('should use the existing team project if credential owning project is found', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const project = await createTeamProject('Sales');
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: {
type: 'team',
teamId: project.id,
teamName: 'Sales',
},
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: project.id,
role: 'credential:owner',
});
expect(sharing).toBeTruthy();
});
it('should change the owner to match source control when credential is owned by somebody else on the target instance', async () => {
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const targetProject = await createTeamProject('Marketing');
const credential = await saveCredential(randomCredentialPayload(), {
project: targetProject,
role: 'credential:owner',
});
const sourceProjectId = nanoid();
const stub: ExportableCredential = {
id: credential.id,
name: 'My Credential',
type: 'someCredentialType',
data: {},
ownedBy: {
type: 'team',
teamId: sourceProjectId,
teamName: 'Sales',
},
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: credential.id })],
importingUser.id,
);
// Verify the source project was created
const sourceProject = await projectRepository.findOne({
where: { id: sourceProjectId },
});
expect(sourceProject).toBeTruthy();
expect(sourceProject?.name).toBe('Sales');
expect(sourceProject?.type).toBe('team');
// Verify ownership changed to match source control
await expect(
sharedCredentialsRepository.findBy({
credentialsId: credential.id,
}),
).resolves.toMatchObject([
{
projectId: sourceProjectId,
role: 'credential:owner',
},
]);
await expect(
credentialsRepository.findBy({
id: credential.id,
}),
).resolves.toMatchObject([
{
name: stub.name,
type: stub.type,
data: 'some-encrypted-data',
},
]);
});
it('should import global credentials with isGlobal flag set to true', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'Global Test Credential',
type: 'globalCredentialType',
data: {},
ownedBy: null,
isGlobal: true,
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const importedCredential = await credentialsRepository.findOneBy({
id: CREDENTIAL_ID,
});
expect(importedCredential).toBeTruthy();
expect(importedCredential?.isGlobal).toBe(true);
expect(importedCredential?.name).toBe('Global Test Credential');
expect(importedCredential?.type).toBe('globalCredentialType');
});
it('should import non-global credentials with isGlobal flag set to false', async () => {
const importingUser = await getGlobalOwner();
jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content'));
const CREDENTIAL_ID = nanoid();
const stub: ExportableCredential = {
id: CREDENTIAL_ID,
name: 'Standard Credential',
type: 'standardCredentialType',
data: {},
ownedBy: null,
isGlobal: false,
};
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
cipher.encryptV2.mockResolvedValue('some-encrypted-data');
await service.importCredentialsFromWorkFolder(
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
importingUser.id,
);
const importedCredential = await credentialsRepository.findOneBy({
id: CREDENTIAL_ID,
});
expect(importedCredential).toBeTruthy();
expect(importedCredential?.isGlobal).toBe(false);
expect(importedCredential?.name).toBe('Standard Credential');
expect(importedCredential?.type).toBe('standardCredentialType');
});
});
describe('importWorkflowFromWorkFolder()', () => {
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
const putWorkflowFile = (workflowId: string, workflow: IWorkflowToImport) => {
const file = `/mock/${workflowId}.json`;
globMock.mockResolvedValue([file]);
mockFileData.set(file, JSON.stringify(workflow));
return file;
};
const makeWorkflowImport = (overrides: Partial<IWorkflowToImport> = {}): IWorkflowToImport => ({
id: overrides.id ?? nanoid(),
name: overrides.name ?? 'Test Workflow',
versionId: overrides.versionId ?? nanoid(),
nodes:
overrides.nodes ??
([
{
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300] as [number, number],
parameters: {},
},
] as IWorkflowToImport['nodes']),
connections: overrides.connections ?? {},
settings: overrides.settings ?? {},
parentFolderId: overrides.parentFolderId ?? null,
active: overrides.active ?? false,
isArchived: overrides.isArchived ?? false,
activeVersionId: overrides.activeVersionId ?? null,
});
beforeEach(() => {
mockFileData.clear();
fsReadFile.mockImplementation(async (path) => {
const pathStr = typeof path === 'string' ? path : path.toString();
if (!mockFileData.has(pathStr)) {
throw new Error(`Trying to access invalid file in test: ${pathStr}`);
}
return mockFileData.get(pathStr)!;
});
});
describe('workflow history', () => {
it('should create workflow history for new workflow on import', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport();
const file = putWorkflowFile(workflow.id, workflow);
await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
);
// Verify workflow history was created
const historyRecord = await workflowHistoryRepository.findOne({
where: { versionId: workflow.versionId, workflowId: workflow.id },
});
expect(historyRecord).toBeTruthy();
expect(historyRecord?.nodes).toEqual(workflow.nodes);
expect(historyRecord?.connections).toEqual(workflow.connections);
expect(historyRecord?.authors).toBe(
`import by ${importingUser.firstName} ${importingUser.lastName}`,
);
});
it('should update workflow history when versionId exists but nodes changed', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
const versionId = nanoid();
// Create initial workflow and history
const initialNodes = [
{
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300] as [number, number],
parameters: {},
},
];
await createWorkflowWithHistory(
{
id: workflowId,
name: 'Test Workflow',
versionId,
nodes: initialNodes,
connections: {},
},
importingUser,
);
// Import with updated nodes
const updatedNodes = [
...initialNodes,
{
id: 'node-2',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [450, 300] as [number, number],
parameters: {},
},
];
const workflow = makeWorkflowImport({ id: workflowId, versionId, nodes: updatedNodes });
const file = putWorkflowFile(workflowId, workflow);
await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
);
// Verify workflow history was updated
const historyRecord = await workflowHistoryRepository.findOne({
where: { versionId, workflowId },
});
expect(historyRecord).toBeTruthy();
expect(historyRecord?.nodes).toEqual(updatedNodes);
expect(historyRecord?.authors).toBe(
`import by ${importingUser.firstName} ${importingUser.lastName}`,
);
});
it('should not update workflow history when versionId exists and content unchanged', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
const versionId = nanoid();
const nodes = [
{
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300] as [number, number],
parameters: {},
},
];
// Create initial workflow and history
await createWorkflowWithHistory(
{
id: workflowId,
name: 'Test Workflow',
versionId,
nodes,
connections: {},
},
importingUser,
);
const historyBefore = await workflowHistoryRepository.findOne({
where: { versionId, workflowId },
});
const workflow = makeWorkflowImport({ id: workflowId, versionId, nodes });
const file = putWorkflowFile(workflowId, workflow);
await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
);
// Verify workflow history was NOT updated (authors should remain unchanged)
const historyAfter = await workflowHistoryRepository.findOne({
where: { versionId, workflowId },
});
expect(historyAfter).toBeTruthy();
expect(historyAfter?.authors).toBe(historyBefore?.authors); // Should not have changed
expect(historyAfter?.updatedAt?.getTime()).toBe(historyBefore?.updatedAt?.getTime());
});
it('should skip workflow when missing versionId', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport();
delete workflow.versionId;
const file = putWorkflowFile(workflow.id, workflow);
const result = await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
);
expect(result).toEqual([]);
// Verify workflow was not created in database
const workflowInDb = await workflowRepository.findOne({ where: { id: workflow.id } });
expect(workflowInDb).toBeNull();
});
it('should skip workflow when missing nodes', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport();
const { nodes, ...workflowWithoutNodes } = workflow;
const file = putWorkflowFile(workflow.id, workflowWithoutNodes as IWorkflowToImport);
const result = await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
);
expect(result).toEqual([]);
// Verify workflow was not created in database
const workflowInDb = await workflowRepository.findOne({ where: { id: workflow.id } });
expect(workflowInDb).toBeNull();
});
it('should skip workflow when missing connections', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport();
const { connections, ...workflowWithoutConnections } = workflow;
const file = putWorkflowFile(workflow.id, workflowWithoutConnections as IWorkflowToImport);
const result = await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
);
expect(result).toEqual([]);
// Verify workflow was not created in database
const workflowInDb = await workflowRepository.findOne({ where: { id: workflow.id } });
expect(workflowInDb).toBeNull();
});
});
});
});