| 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', () => { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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', |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| [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), |
| ); |
|
|
| |
| service.sanityCheck = async () => {}; |
| service.resetWorkfolder = async () => undefined; |
|
|
| |
| 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)) { |
| |
| return Object.keys(gitFiles).filter((file) => |
| file.startsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER), |
| ); |
| } else if (opts.cwd?.endsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) { |
| |
| return Object.keys(gitFiles).filter((file) => |
| file.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER), |
| ); |
| } else if (path === SOURCE_CONTROL_FOLDERS_EXPORT_FILE) { |
| |
| return ['folders.json']; |
| } else if (path === SOURCE_CONTROL_TAGS_EXPORT_FILE) { |
| |
| 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'); |
| } |
|
|
| |
| 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', |
| ); |
|
|
| |
| 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', |
| ); |
|
|
| |
| 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', |
| ); |
|
|
| |
| 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(); |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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', |
| ); |
|
|
| |
| expect(new Set(deletedWorkflows.map((wf) => wf.id))).toEqual( |
| new Set([deletedInScopeWorkflow.id]), |
| ); |
|
|
| const newWorkflows = result.filter( |
| (r) => r.type === 'workflow' && r.status === 'created', |
| ); |
|
|
| |
| 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', |
| ); |
|
|
| |
| 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(); |
|
|
| |
| 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 () => { |
| |
| 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); |
| 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; |
|
|
| const filesToWrite = |
| allChanges.filter( |
| (change) => |
| (change.type === 'workflow' || change.type === 'credential') && |
| change.status !== 'deleted', |
| ).length + 2; |
|
|
| 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, |
| ); |
| }); |
| }); |
|
|
| 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); |
| 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 () => { |
| |
| gitFiles['tags.json'] = { |
| tags: tags.map((t) => ({ |
| id: t.id, |
| name: t.name, |
| })), |
| mappings: allWorkflows.map((wf) => ({ |
| workflowId: wf.id, |
| tagId: tags[0].id, |
| })), |
| }; |
|
|
| |
| await updateTag(tags[0], { name: 'updatedTag1' }); |
|
|
| |
| 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'); |
| |
| |
| |
| 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); |
|
|
| |
| |
| expect( |
| foldersFileContent.folders.find((t: any) => t.homeProjectId === projectB.id).id, |
| ).toBe(projectBScope.folders[0].id); |
|
|
| |
| 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); |
| }); |
| }); |
| }); |
| }); |
|
|