Files
n8n-enterprise-unlocked/packages/cli/test/integration/environments/source-control.service.test.ts
2025-06-20 11:33:17 +02:00

1138 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { SourceControlledFile } from '@n8n/api-types';
import {
CredentialsEntity,
type Folder,
FolderRepository,
Project,
type TagEntity,
TagRepository,
type User,
WorkflowEntity,
} 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 fsp from 'node:fs/promises';
import { basename, isAbsolute } from 'node:path';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from '@/environments.ee/source-control/constants';
import { SourceControlExportService } from '@/environments.ee/source-control/source-control-export.service.ee';
import type { SourceControlGitService } from '@/environments.ee/source-control/source-control-git.service.ee';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import type { ExportableFolder } from '@/environments.ee/source-control/types/exportable-folders';
import type { ExportableWorkflow } from '@/environments.ee/source-control/types/exportable-workflow';
import type { RemoteResourceOwner } from '@/environments.ee/source-control/types/resource-owner';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import { createCredentials } from '@test-integration/db/credentials';
import { createFolder } from '@test-integration/db/folders';
import { createTeamProject } from '@test-integration/db/projects';
import { assignTagToWorkflow, createTag, updateTag } from '@test-integration/db/tags';
import { createUser } from '@test-integration/db/users';
import { createWorkflow } from '@test-integration/db/workflows';
import * as testDb from '../shared/test-db';
jest.mock('fast-glob');
type Scope = {
workflows: WorkflowEntity[];
credentials: CredentialsEntity[];
folders: Folder[];
};
let sourceControlPreferencesService: SourceControlPreferencesService;
function toExportableFolder(folder: Folder): ExportableFolder {
return {
id: folder.id,
name: folder.name,
homeProjectId: folder.homeProject.id,
parentFolderId: folder.parentFolderId,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
};
}
function toExportableCredential(
cred: CredentialsEntity,
owner: Project | User,
): ExportableCredential {
let resourceOwner: RemoteResourceOwner;
if (owner instanceof Project) {
resourceOwner = {
type: 'team',
teamId: owner.id,
teamName: owner.name,
};
} else {
resourceOwner = {
type: 'personal',
personalEmail: owner.email,
};
}
return {
id: cred.id,
data: {},
name: cred.name,
type: cred.type,
ownedBy: resourceOwner,
};
}
function toExportableWorkflow(
wf: WorkflowEntity,
owner: Project | User,
versionId?: string,
): ExportableWorkflow {
let resourceOwner: RemoteResourceOwner;
if (owner instanceof Project) {
resourceOwner = {
type: 'team',
teamId: owner.id,
teamName: owner.name,
};
} else {
resourceOwner = {
type: 'personal',
personalEmail: owner.email,
};
}
return {
id: wf.id,
name: wf.name,
connections: wf.connections,
isArchived: wf.isArchived,
nodes: wf.nodes,
owner: resourceOwner,
triggerCount: wf.triggerCount,
parentFolderId: null,
versionId: versionId ?? wf.versionId,
};
}
describe('SourceControlService', () => {
/*
Test scenarios (push):
1. globalAdmin
sees everything, workflows in different projects, credentials in different projects, tags and mappings in different projects, folders in different projects
2. globalOwner
same as global Admin
3. globalMember
sees nothing ...
4. projectAdmin (global member)
sees workflows in his team projects only, credentials in his team projects only, same for mappings and folders, sees all tags
5. projectMember
sees nothing
Test scenarios (pull):
TBD!
*/
let globalAdmin: User;
let globalOwner: User;
let globalMember: User;
let projectAdmin: User;
let projectA: Project;
let projectB: Project;
let globalAdminScope: Scope;
let globalOwnerScope: Scope;
let globalMemberScope: Scope;
let projectAdminScope: Scope;
let projectAScope: Scope;
let projectBScope: Scope;
let allWorkflows: WorkflowEntity[];
let tags: TagEntity[];
let gitFiles: Record<string, unknown>;
let movedOutOfScopeWorkflow: WorkflowEntity;
let movedIntoScopeWorkflow: WorkflowEntity;
let deletedOutOfScopeWorkflow: WorkflowEntity;
let deletedInScopeWorkflow: WorkflowEntity;
let movedOutOfScopeCredential: CredentialsEntity;
let movedIntoScopeCredential: CredentialsEntity;
let deletedOutOfScopeCredential: CredentialsEntity;
let deletedInScopeCredential: CredentialsEntity;
let gitService: SourceControlGitService;
let service: SourceControlService;
let cipher: Cipher;
const globMock = fastGlob.default as unknown as jest.Mock<
Promise<string[]>,
[fastGlob.Pattern | fastGlob.Pattern[], fastGlob.Options]
>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
const fsWriteFile = jest.spyOn(fsp, 'writeFile');
beforeAll(async () => {
await testDb.init();
cipher = Container.get(Cipher);
sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
});
/*
Set up test conditions:
4 users:
globalAdmin
globalOwner
globalMember
projectAdmin
2 Team projects:
ProjectA (admin == projectAdmin)
ProjectB
2 Workflows per Team and User
2 Credentials per Team
3 Tags
Mappings to all workflows
for each project 3 folders 2 top level, 1 child
1. Workflow moved in git to other project
*/
[globalAdmin, globalOwner, globalMember, projectAdmin] = await Promise.all([
await createUser({ role: 'global:admin' }),
await createUser({ role: 'global:owner' }),
await createUser({ role: 'global:member' }),
await createUser({ role: 'global:member' }),
]);
[projectA, projectB] = await Promise.all([
createTeamProject('ProjectA', projectAdmin),
createTeamProject('ProjectB'),
]);
let [
globalAdminWorkflows,
globalOwnerWorkflows,
globalMemberWorkflows,
projectAdminWorkflows,
projectAWorkflows,
projectBWorkflows,
] = await Promise.all(
[globalAdmin, globalOwner, globalMember, projectAdmin, projectA, projectB].map(
async (owner) => [
await createWorkflow(
{
name: `${owner.id}-WFA`,
},
owner,
),
await createWorkflow(
{
name: `${owner.id}-WFB`,
},
owner,
),
],
),
);
allWorkflows = [
...globalAdminWorkflows,
...globalOwnerWorkflows,
...globalMemberWorkflows,
...projectAdminWorkflows,
...projectAWorkflows,
...projectBWorkflows,
];
deletedOutOfScopeWorkflow = Object.assign(new WorkflowEntity(), {
id: 'deletedOutOfScope',
name: 'deletedOutOfScope',
});
deletedInScopeWorkflow = Object.assign(new WorkflowEntity(), {
id: 'deletedInScope',
name: 'deletedInScope',
});
deletedInScopeCredential = Object.assign(new CredentialsEntity(), {
id: 'deletedInScope',
name: 'deletedInScope',
data: cipher.encrypt({}),
type: '',
});
deletedOutOfScopeCredential = Object.assign(new CredentialsEntity(), {
id: 'deletedOutOfScope',
name: 'deletedOutOfScope',
data: cipher.encrypt({}),
type: '',
});
[
movedOutOfScopeCredential,
movedIntoScopeCredential,
movedOutOfScopeWorkflow,
movedIntoScopeWorkflow,
] = await Promise.all([
await createCredentials(
{
name: 'OutOfScope',
data: cipher.encrypt({}),
type: '',
},
projectB,
),
await createCredentials(
{
name: 'IntoScope',
data: cipher.encrypt({}),
type: '',
},
projectA,
),
await createWorkflow(
{
name: 'OutOfScope',
},
projectB,
),
await createWorkflow(
{
name: 'IntoScope',
},
projectA,
),
]);
let [projectACredentials, projectBCredentials] = await Promise.all(
[projectA, projectB].map(async (project) => [
await createCredentials(
{
name: `${project.name}-CredA`,
data: cipher.encrypt({}),
type: '',
},
project,
),
await createCredentials(
{
name: `${project.name}-CredB`,
data: cipher.encrypt({}),
type: '',
},
project,
),
]),
);
tags = await Promise.all([
createTag({
name: 'testTag1',
}),
createTag({
name: 'testTag2',
}),
createTag({
name: 'testTag3',
}),
]);
await Promise.all(
tags.map(async (tag) => {
await Promise.all(
allWorkflows.map(async (workflow) => {
await assignTagToWorkflow(tag, workflow);
}),
);
}),
);
let [projectAFolders, projectBFolders] = await Promise.all(
[projectA, projectB].map(async (project) => {
const parent = await createFolder(project, {
name: `${project.name}-FolderA`,
});
return [
parent,
await createFolder(project, {
name: `${project.name}-FolderB`,
}),
await createFolder(project, {
name: `${project.name}-FolderA.1`,
parentFolder: parent,
}),
];
}),
);
globalAdminScope = {
credentials: [],
workflows: globalAdminWorkflows,
folders: [],
};
globalOwnerScope = {
credentials: [],
workflows: globalOwnerWorkflows,
folders: [],
};
globalMemberScope = {
credentials: [],
workflows: globalMemberWorkflows,
folders: [],
};
projectAdminScope = {
credentials: [],
workflows: projectAdminWorkflows,
folders: [],
};
projectAScope = {
credentials: projectACredentials,
folders: projectAFolders,
workflows: projectAWorkflows,
};
projectBScope = {
credentials: projectBCredentials,
folders: projectBFolders,
workflows: projectBWorkflows,
};
gitService = mock<SourceControlGitService>();
service = new SourceControlService(
mock(),
gitService,
sourceControlPreferencesService,
Container.get(SourceControlExportService),
Container.get(SourceControlImportService),
Container.get(TagRepository),
Container.get(FolderRepository),
Container.get(EventService),
);
// Skip actual git operations
service.sanityCheck = async () => {};
service.resetWorkfolder = async () => undefined;
// Git mocking
gitFiles = {
'workflows/deletedOutOfScope.json': toExportableWorkflow(deletedOutOfScopeWorkflow, projectB),
'workflows/deletedInScope.json': toExportableWorkflow(deletedInScopeWorkflow, projectA),
'workflows/globalAdminWFA.json': toExportableWorkflow(globalAdminWorkflows[0], globalAdmin),
'workflows/globalOwnerWFA.json': toExportableWorkflow(globalOwnerWorkflows[0], globalOwner),
'workflows/globalMemberWFA.json': toExportableWorkflow(
globalMemberWorkflows[0],
globalMember,
),
'workflows/projectAdminWFA.json': toExportableWorkflow(
projectAdminWorkflows[0],
projectAdmin,
),
'workflows/projectAWFA.json': toExportableWorkflow(projectAWorkflows[0], projectA),
'workflows/projectBWFA.json': toExportableWorkflow(projectBWorkflows[0], projectB),
'workflows/outofscope.json': toExportableWorkflow(
movedOutOfScopeWorkflow,
projectA,
'otherID',
),
'workflows/intoscope.json': toExportableWorkflow(movedIntoScopeWorkflow, projectB, 'otherID'),
'credential_stubs/AcredA.json': toExportableCredential(projectACredentials[0], projectA),
'credential_stubs/BcredA.json': toExportableCredential(projectBCredentials[0], projectB),
'credential_stubs/movedOutOfScopeCred.json': toExportableCredential(
movedOutOfScopeCredential,
projectB,
),
'credential_stubs/movedIntoScopeCred.json': toExportableCredential(
movedIntoScopeCredential,
projectA,
),
'credential_stubs/deletedOutOfScopeCred.json': toExportableCredential(
deletedOutOfScopeCredential,
projectB,
),
'credential_stubs/deletedIntoScopeCred.json': toExportableCredential(
deletedInScopeCredential,
projectA,
),
'folders.json': {
folders: [toExportableFolder(projectAFolders[0]), toExportableFolder(projectBFolders[0])],
},
'tags.json': {
tags: tags.map((t) => {
return {
id: t.id,
name: t.name,
};
}),
mappings: [
...globalAdminWorkflows.map((m) => {
return {
workflowId: m.id,
tagId: tags[0].id,
};
}),
],
},
};
globMock.mockImplementation(async (path, opts) => {
if (opts.cwd?.endsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER)) {
// asking for workflows
return Object.keys(gitFiles).filter((file) =>
file.startsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER),
);
} else if (opts.cwd?.endsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
// asking for credentials
return Object.keys(gitFiles).filter((file) =>
file.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER),
);
} else if (path === SOURCE_CONTROL_FOLDERS_EXPORT_FILE) {
// asking for folders
return ['folders.json'];
} else if (path === SOURCE_CONTROL_TAGS_EXPORT_FILE) {
// asking for folders
return ['tags.json'];
}
return [];
});
fsReadFile.mockImplementation(async (path: string) => {
const pathWithoutCwd = isAbsolute(path) ? basename(path) : path;
return JSON.stringify(gitFiles[pathWithoutCwd]);
});
});
afterAll(async () => {
await testDb.terminate();
});
describe('getStatus', () => {
describe('direction: push', () => {
describe('global:admin user', () => {
it('should see all workflows', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
// not existing in get status response
const notExisting = result.filter((wf) => {
return [
globalAdminScope.workflows[0],
globalOwnerScope.workflows[0],
globalMemberScope.workflows[0],
projectAdminScope.workflows[0],
projectAScope.workflows[0],
projectBScope.workflows[0],
]
.map((wf) => wf.id)
.some((id) => wf.id === id);
});
expect(notExisting).toBeEmptyArray();
const deletedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'deleted',
);
// The created workflows
expect(new Set(deletedWorkflows.map((wf) => wf.id))).toEqual(
new Set([deletedOutOfScopeWorkflow.id, deletedInScopeWorkflow.id]),
);
const newWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'created',
);
// The created workflows
expect(new Set(newWorkflows.map((wf) => wf.id))).toEqual(
new Set([
globalAdminScope.workflows[1].id,
globalOwnerScope.workflows[1].id,
globalMemberScope.workflows[1].id,
projectAdminScope.workflows[1].id,
projectAScope.workflows[1].id,
projectBScope.workflows[1].id,
]),
);
const modifiedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'modified',
);
// The modified workflows
expect(new Set(modifiedWorkflows.map((wf) => wf.id))).toEqual(
new Set([movedOutOfScopeWorkflow.id, movedIntoScopeWorkflow.id]),
);
});
it('should see all credentials', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const newCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'created',
);
const deletedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'deleted',
);
const modifiedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'modified',
);
expect(new Set(newCredentials.map((c) => c.id))).toEqual(
new Set([projectAScope.credentials[1].id, projectBScope.credentials[1].id]),
);
expect(new Set(deletedCredentials.map((c) => c.id))).toEqual(
new Set([deletedInScopeCredential.id, deletedOutOfScopeCredential.id]),
);
expect(modifiedCredentials).toBeEmptyArray();
// Make sure we checked all credential entries!
expect(result.filter((r) => r.type === 'credential')).toHaveLength(4);
});
it('should see all folder', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const folders = result.filter((r) => r.type === 'folders');
expect(new Set(folders.map((f) => f.id))).toEqual(
new Set([
projectAScope.folders[1].id,
projectAScope.folders[2].id,
projectBScope.folders[1].id,
projectBScope.folders[2].id,
]),
);
});
});
describe('global:member user', () => {
it('should see nothing', async () => {
let result = await service.getStatus(globalMember, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(result).toBeEmptyArray();
});
});
describe('project:Admin user', () => {
it('should see only workflows in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
// not existing in get status response
const notExisting = result.filter((wf) => {
return [
globalAdminScope.workflows[0],
globalOwnerScope.workflows[0],
globalMemberScope.workflows[0],
projectAdminScope.workflows[0],
globalAdminScope.workflows[1],
globalOwnerScope.workflows[1],
globalMemberScope.workflows[1],
projectAdminScope.workflows[1],
projectAScope.workflows[0],
projectBScope.workflows[0],
movedOutOfScopeWorkflow,
]
.map((wf) => wf.id)
.some((id) => wf.id === id);
});
expect(notExisting).toBeEmptyArray();
const deletedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'deleted',
);
// The created workflows
expect(new Set(deletedWorkflows.map((wf) => wf.id))).toEqual(
new Set([deletedInScopeWorkflow.id]),
);
const newWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'created',
);
// The created workflows
expect(new Set(newWorkflows.map((wf) => wf.id))).toEqual(
new Set([projectAScope.workflows[1].id, movedIntoScopeWorkflow.id]),
);
const modifiedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'modified',
);
// No modified workflows
expect(modifiedWorkflows).toBeEmptyArray();
});
it('should see only credentials in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const newCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'created',
);
const deletedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'deleted',
);
const modifiedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'modified',
);
expect(new Set(newCredentials.map((c) => c.id))).toEqual(
new Set([projectAScope.credentials[1].id]),
);
expect(new Set(deletedCredentials.map((c) => c.id))).toEqual(
new Set([deletedInScopeCredential.id]),
);
expect(modifiedCredentials).toBeEmptyArray();
// Make sure we checked all credential entries!
expect(result.filter((r) => r.type === 'credential')).toHaveLength(2);
});
it('should see only folders in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const folders = result.filter((r) => r.type === 'folders');
expect(new Set(folders.map((f) => f.id))).toEqual(
new Set([projectAScope.folders[1].id, projectAScope.folders[2].id]),
);
});
});
});
});
describe('pushWorkfolder', () => {
const updatedFiles: Record<string, string> = {};
beforeAll(async () => {
// Reset the git service mock for tags
gitFiles['tags.json'] = {
tags: [],
mappings: [],
};
});
beforeEach(() => {
fsWriteFile.mockImplementation(async (path, data) => {
updatedFiles[path as string] = data as string;
});
});
afterEach(() => {
fsWriteFile.mockReset();
for (const key in updatedFiles) {
delete updatedFiles[key];
}
});
describe('on readonly instance', () => {
beforeAll(async () => {
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
branchReadOnly: true,
});
});
afterAll(async () => {
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
branchReadOnly: false,
});
});
it('should fail with BadRequest', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
await expect(
service.pushWorkfolder(globalMember, {
fileNames: allChanges,
commitMessage: 'Test',
}),
).rejects.toThrowError(BadRequestError);
});
});
describe('global:admin user', () => {
it('should update all workflows, credentials, tags and folder', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const result = await service.pushWorkfolder(globalAdmin, {
fileNames: allChanges,
commitMessage: 'Test',
force: true,
});
const workflowFiles = result.statusResult
.filter((change) => change.type === 'workflow' && change.status !== 'deleted')
.map((change) => change.file);
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(8);
expect(credentialFiles).toHaveLength(2);
expect(gitService.push).toBeCalled();
expect(fsWriteFile).toBeCalledTimes(workflowFiles.length + credentialFiles.length + 2); // folders + tags
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_FOLDERS_EXPORT_FILE)]),
);
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_TAGS_EXPORT_FILE)]),
);
});
it('should update all workflows and credentials without arguments', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const result = await service.pushWorkfolder(globalAdmin, {
fileNames: [],
commitMessage: 'Test',
force: true,
});
const workflowFiles = result.statusResult
.filter((change) => change.type === 'workflow' && change.status !== 'deleted')
.map((change) => change.file);
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(8);
expect(credentialFiles).toHaveLength(2);
const numberFilesToWrite = workflowFiles.length + credentialFiles.length + 2; // folders + tags
const filesToWrite =
allChanges.filter(
(change) =>
(change.type === 'workflow' || change.type === 'credential') &&
change.status !== 'deleted',
).length + 2; // folders + tags
expect(numberFilesToWrite).toBe(filesToWrite);
expect(fsWriteFile).toBeCalledTimes(filesToWrite);
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_FOLDERS_EXPORT_FILE)]),
);
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_TAGS_EXPORT_FILE)]),
);
const tagFile = result.statusResult.find(
(change) => change.type === 'tags' && change.status !== 'deleted',
);
const tagsFile = JSON.parse(updatedFiles[tagFile!.file]);
expect(tagsFile.mappings).toHaveLength(
allWorkflows.length * tags.length, // all workflows have all tags assigned
);
});
});
describe('project:admin', () => {
it('should update selected workflows, credentials, tags and folders', async () => {
let allChanges = (await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const result = await service.pushWorkfolder(projectAdmin, {
fileNames: allChanges,
commitMessage: 'Test',
force: true,
});
const workflowFiles = result.statusResult
.filter((change) => change.type === 'workflow' && change.status !== 'deleted')
.map((change) => change.file);
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(2);
expect(credentialFiles).toHaveLength(1);
expect(fsWriteFile).toBeCalledTimes(workflowFiles.length + credentialFiles.length + 2); // folders + tags
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_FOLDERS_EXPORT_FILE)]),
);
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_TAGS_EXPORT_FILE)]),
);
});
it('should throw ForbiddenError when trying to push workflows out of scope', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const workflowOutOfScope = allChanges.find(
(wf) =>
wf.type === 'workflow' && !projectAdminScope.workflows.some((w) => w.id === wf.id),
);
await expect(
service.pushWorkfolder(projectAdmin, {
fileNames: [workflowOutOfScope!],
commitMessage: 'Test',
force: true,
}),
).rejects.toThrowError(ForbiddenError);
});
it('should throw ForbiddenError when trying to push credentials out of scope', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const credentialOutOfScope = allChanges.find(
(cred) =>
cred.type === 'credential' &&
!projectAdminScope.credentials.some((c) => c.id === cred.id),
);
await expect(
service.pushWorkfolder(projectAdmin, {
fileNames: [credentialOutOfScope!],
commitMessage: 'Test',
force: true,
}),
).rejects.toThrowError(ForbiddenError);
});
it('should update tag mappings in scope and keep out of scope ones', async () => {
// Reset the git service mock for tags
gitFiles['tags.json'] = {
tags: tags.map((t) => ({
id: t.id,
name: t.name,
})),
mappings: allWorkflows.map((wf) => ({
workflowId: wf.id,
tagId: tags[0].id,
})),
};
// Update a tag name
await updateTag(tags[0], { name: 'updatedTag1' });
// Add a new tag to newly assigned workflow
await assignTagToWorkflow(tags[1], movedIntoScopeWorkflow);
let allChanges = (await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const tagsFile = allChanges.find((file) =>
file.file.includes(SOURCE_CONTROL_TAGS_EXPORT_FILE),
);
expect(tagsFile).toBeDefined();
const result = await service.pushWorkfolder(projectAdmin, {
fileNames: [tagsFile!],
commitMessage: 'Test',
force: true,
});
expect(result.statusResult).toHaveLength(1);
expect(result.statusResult[0].type).toBe('tags');
expect(result.statusResult[0].status).toBe('modified');
expect(result.statusResult[0].file).toContain(SOURCE_CONTROL_TAGS_EXPORT_FILE);
const tagsFileContent = JSON.parse(updatedFiles[result.statusResult[0].file]);
expect(tagsFileContent.tags).toHaveLength(3);
expect(tagsFileContent.tags.find((t: any) => t.id === tags[0].id).name).toBe('updatedTag1'); // updated tag name
// all workflows have all 1 tag assigned on git files
// + 2 new mapping for project A workflows
// + 1 new mapping for moved into scope workflow
expect(tagsFileContent.mappings).toHaveLength(allWorkflows.length + 2 * 2 + 1);
});
it('should update folders in scope and keep out of scope ones', async () => {
let allChanges = (await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
const foldersFile = allChanges.find((file) =>
file.file.includes(SOURCE_CONTROL_FOLDERS_EXPORT_FILE),
);
expect(foldersFile).toBeDefined();
const result = await service.pushWorkfolder(projectAdmin, {
fileNames: [foldersFile!],
commitMessage: 'Test',
force: true,
});
expect(result.statusResult).toHaveLength(1);
expect(result.statusResult[0].type).toBe('folders');
expect(result.statusResult[0].status).toBe('created');
expect(result.statusResult[0].file).toContain(SOURCE_CONTROL_FOLDERS_EXPORT_FILE);
const foldersFileContent = JSON.parse(updatedFiles[result.statusResult[0].file]);
expect(foldersFileContent.folders).toHaveLength(4);
// We make sure that we still hold the folder that belongs to project B
// to which this user doesn't have access
expect(
foldersFileContent.folders.find((t: any) => t.homeProjectId === projectB.id).id,
).toBe(projectBScope.folders[0].id);
// Ensure that all folders from project A are written to the git file
expect(foldersFileContent.folders.map((f: any) => f.id)).toEqual(
expect.arrayContaining(projectAScope.folders.map((f) => f.id)),
);
});
});
describe('global:member', () => {
it('should deny all changes', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
await expect(
service.pushWorkfolder(globalMember, {
fileNames: allChanges,
commitMessage: 'Test',
}),
).rejects.toThrowError(ForbiddenError);
});
it('should deny any changes', async () => {
let allChanges = (await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
})) as SourceControlledFile[];
await expect(
service.pushWorkfolder(globalMember, {
fileNames: [allChanges[0]],
commitMessage: 'Test',
}),
).rejects.toThrowError(ForbiddenError);
});
});
});
});