mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
1138 lines
33 KiB
TypeScript
1138 lines
33 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|
||
});
|