| import type { SourceControlledFile } from '@n8n/api-types'; |
| import { |
| type CredentialsEntity, |
| CredentialsRepository, |
| type Folder, |
| type Project, |
| type TagEntity, |
| TagRepository, |
| type User, |
| type WorkflowEntity, |
| WorkflowRepository, |
| WorkflowTagMappingRepository, |
| } from '@n8n/db'; |
| import { FolderRepository } from '@n8n/db'; |
| import { ProjectRepository } from '@n8n/db'; |
| import { SharedCredentialsRepository } from '@n8n/db'; |
| import { UserRepository } from '@n8n/db'; |
| import { Container } from '@n8n/di'; |
| import * as fastGlob from 'fast-glob'; |
| import { mock } from 'jest-mock-extended'; |
| import { Cipher } from 'n8n-core'; |
| import type { InstanceSettings } from 'n8n-core'; |
| import * as utils from 'n8n-workflow'; |
| import { nanoid } from 'nanoid'; |
| import fsp from 'node:fs/promises'; |
|
|
| import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee'; |
| import { SourceControlScopedService } from '@/environments.ee/source-control/source-control-scoped.service'; |
| import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; |
| import { SourceControlContext } from '@/environments.ee/source-control/types/source-control-context'; |
| import type { IWorkflowToImport } from '@/interfaces'; |
| import { createFolder } from '@test-integration/db/folders'; |
| import { assignTagToWorkflow, createTag } from '@test-integration/db/tags'; |
|
|
| import { mockInstance } from '../../shared/mocking'; |
| import { createCredentials, saveCredential } from '../shared/db/credentials'; |
| import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects'; |
| import { createAdmin, createMember, createOwner, getGlobalOwner } from '../shared/db/users'; |
| import { createWorkflow } from '../shared/db/workflows'; |
| import { randomCredentialPayload } from '../shared/random'; |
| import * as testDb from '../shared/test-db'; |
|
|
| jest.mock('fast-glob'); |
|
|
| describe('SourceControlImportService', () => { |
| let credentialsRepository: CredentialsRepository; |
| let projectRepository: ProjectRepository; |
| let sharedCredentialsRepository: SharedCredentialsRepository; |
| let userRepository: UserRepository; |
| let folderRepository: FolderRepository; |
| let service: SourceControlImportService; |
| let workflowRepository: WorkflowRepository; |
| let tagRepository: TagRepository; |
| let workflowTagMappingRepository: WorkflowTagMappingRepository; |
| let sourceControlScopedService: SourceControlScopedService; |
|
|
| const cipher = mockInstance(Cipher); |
|
|
| beforeAll(async () => { |
| await testDb.init(); |
|
|
| credentialsRepository = Container.get(CredentialsRepository); |
| projectRepository = Container.get(ProjectRepository); |
| sharedCredentialsRepository = Container.get(SharedCredentialsRepository); |
| userRepository = Container.get(UserRepository); |
| folderRepository = Container.get(FolderRepository); |
| workflowRepository = Container.get(WorkflowRepository); |
| tagRepository = Container.get(TagRepository); |
| workflowTagMappingRepository = Container.get(WorkflowTagMappingRepository); |
| sourceControlScopedService = Container.get(SourceControlScopedService); |
| service = new SourceControlImportService( |
| mock(), |
| mock(), |
| mock(), |
| mock(), |
| credentialsRepository, |
| projectRepository, |
| tagRepository, |
| mock(), |
| sharedCredentialsRepository, |
| userRepository, |
| mock(), |
| workflowRepository, |
| workflowTagMappingRepository, |
| mock(), |
| mock(), |
| mock(), |
| folderRepository, |
| mock<InstanceSettings>({ n8nFolder: '/some-path' }), |
| sourceControlScopedService, |
| ); |
| }); |
|
|
| afterEach(async () => { |
| await testDb.truncate(['CredentialsEntity', 'SharedCredentials']); |
|
|
| jest.restoreAllMocks(); |
| }); |
|
|
| afterAll(async () => { |
| await testDb.terminate(); |
| }); |
|
|
| describe('getRemoteVersionIdsFromFiles()', () => { |
| const mockWorkflow1File = '/mock/workflow1.json'; |
| const mockWorkflow2File = '/mock/workflow2.json'; |
| const mockWorkflow3File = '/mock/workflow3.json'; |
| const mockWorkflow4File = '/mock/workflow4.json'; |
| const mockWorkflow5File = '/mock/workflow5.json'; |
|
|
| const mockWorkflow1Data: Partial<IWorkflowToImport> = { |
| id: 'workflow1', |
| versionId: 'v1', |
| name: 'Test Workflow', |
| owner: { |
| type: 'personal', |
| personalEmail: 'someuser@example.com', |
| }, |
| }; |
| const mockWorkflow2Data: Partial<IWorkflowToImport> = { |
| id: 'workflow2', |
| versionId: 'v1', |
| name: 'Test Workflow', |
| owner: { |
| type: 'team', |
| teamId: 'team1', |
| teamName: 'Team 1', |
| }, |
| }; |
| const mockWorkflow3Data: Partial<IWorkflowToImport> = { |
| id: 'workflow3', |
| versionId: 'v1', |
| name: 'Test Workflow', |
| owner: { |
| type: 'team', |
| teamId: 'team2', |
| teamName: 'Team 2', |
| }, |
| }; |
| const mockWorkflow4Data: Partial<IWorkflowToImport> = { |
| id: 'workflow4', |
| versionId: 'v1', |
| name: 'Test Workflow', |
| owner: { |
| type: 'personal', |
| personalEmail: 'someotheruser@example.com', |
| }, |
| }; |
| const mockWorkflow5Data: Partial<IWorkflowToImport> = { |
| id: 'workflow5', |
| versionId: 'v1', |
| name: 'Test Workflow', |
| owner: { |
| type: 'team', |
| teamId: 'team1', |
| teamName: 'Team 1', |
| }, |
| }; |
|
|
| const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>; |
| const fsReadFile = jest.spyOn(fsp, 'readFile'); |
|
|
| let globalAdmin: User; |
| let globalOwner: User; |
| let globalMember: User; |
| let teamAdmin: User; |
| let team1: Project; |
|
|
| beforeAll(async () => { |
| [globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([ |
| createAdmin(), |
| createOwner(), |
| createMember(), |
| createMember(), |
| ]); |
|
|
| team1 = await createTeamProject('Team 1', teamAdmin); |
| }); |
|
|
| beforeEach(async () => { |
| globMock.mockImplementation(async () => [ |
| mockWorkflow1File, |
| mockWorkflow2File, |
| mockWorkflow3File, |
| mockWorkflow4File, |
| mockWorkflow5File, |
| ]); |
|
|
| fsReadFile.mockImplementation(async (path) => { |
| switch (path) { |
| case mockWorkflow1File: |
| return JSON.stringify({ |
| ...mockWorkflow1Data, |
| owner: { |
| type: 'personal', |
| personalEmail: teamAdmin.email, |
| }, |
| }); |
| case mockWorkflow2File: |
| return JSON.stringify({ |
| ...mockWorkflow2Data, |
| owner: { |
| type: 'team', |
| teamId: team1.id, |
| teamName: team1.name, |
| }, |
| }); |
| case mockWorkflow3File: |
| return JSON.stringify(mockWorkflow3Data); |
| case mockWorkflow4File: |
| return JSON.stringify(mockWorkflow4Data); |
| case mockWorkflow5File: |
| return JSON.stringify({ |
| ...mockWorkflow5Data, |
| owner: { |
| type: 'team', |
| teamId: team1.id, |
| teamName: team1.name, |
| }, |
| }); |
| } |
| throw new Error(`Trying to access invalid file in test: ${path}`); |
| }); |
| }); |
|
|
| it('should show all remote workflows for instance admins', async () => { |
| const result = await service.getRemoteVersionIdsFromFiles( |
| new SourceControlContext(globalAdmin), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set( |
| [ |
| mockWorkflow1Data, |
| mockWorkflow2Data, |
| mockWorkflow3Data, |
| mockWorkflow4Data, |
| mockWorkflow5Data, |
| ].map((r) => r.id), |
| ), |
| ); |
| }); |
|
|
| it('should show all remote workflows for instance owners', async () => { |
| const result = await service.getRemoteVersionIdsFromFiles( |
| new SourceControlContext(globalOwner), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set( |
| [ |
| mockWorkflow1Data, |
| mockWorkflow2Data, |
| mockWorkflow3Data, |
| mockWorkflow4Data, |
| mockWorkflow5Data, |
| ].map((r) => r.id), |
| ), |
| ); |
| }); |
|
|
| it('should return no remote workflows for instance members', async () => { |
| const result = await service.getRemoteVersionIdsFromFiles( |
| new SourceControlContext(globalMember), |
| ); |
|
|
| expect(result).toBeEmptyArray(); |
| }); |
|
|
| it('should return only remote workflows that belong to team project', async () => { |
| const result = await service.getRemoteVersionIdsFromFiles( |
| new SourceControlContext(teamAdmin), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set([mockWorkflow2Data, mockWorkflow5Data].map((r) => r.id)), |
| ); |
| }); |
| }); |
|
|
| describe('getLocalVersionIdsFromDb()', () => { |
| let instanceOwner: User; |
| let projectAdmin: User; |
| let projectMember: User; |
| let teamProjectA: Project; |
| let teamProjectB: Project; |
| let teamAWorkflows: WorkflowEntity[]; |
| let teamBWorkflows: WorkflowEntity[]; |
| let instanceOwnerWorkflows: WorkflowEntity[]; |
| let projectAdminWorkflows: WorkflowEntity[]; |
| let projectMemberWorkflows: WorkflowEntity[]; |
|
|
| beforeAll(async () => { |
| [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ |
| getGlobalOwner(), |
| createMember(), |
| createMember(), |
| createTeamProject(), |
| createTeamProject(), |
| ]); |
|
|
| await linkUserToProject(projectAdmin, teamProjectA, 'project:admin'); |
| await linkUserToProject(projectMember, teamProjectA, 'project:editor'); |
| await linkUserToProject(projectAdmin, teamProjectB, 'project:editor'); |
| await linkUserToProject(projectMember, teamProjectB, 'project:editor'); |
|
|
| teamAWorkflows = await Promise.all([ |
| await createWorkflow({}, teamProjectA), |
| await createWorkflow({}, teamProjectA), |
| await createWorkflow({}, teamProjectA), |
| ]); |
|
|
| teamBWorkflows = await Promise.all([ |
| await createWorkflow({}, teamProjectB), |
| await createWorkflow({}, teamProjectB), |
| await createWorkflow({}, teamProjectB), |
| ]); |
|
|
| instanceOwnerWorkflows = await Promise.all([ |
| await createWorkflow({}, instanceOwner), |
| await createWorkflow({}, instanceOwner), |
| await createWorkflow({}, instanceOwner), |
| ]); |
|
|
| projectAdminWorkflows = await Promise.all([ |
| await createWorkflow({}, projectAdmin), |
| await createWorkflow({}, projectAdmin), |
| await createWorkflow({}, projectAdmin), |
| ]); |
|
|
| projectMemberWorkflows = await Promise.all([ |
| await createWorkflow({}, projectMember), |
| await createWorkflow({}, projectMember), |
| await createWorkflow({}, projectMember), |
| ]); |
| }); |
|
|
| describe('if user is an instance owner', () => { |
| it('should get all available workflows on the instance', async () => { |
| let versions = await service.getLocalVersionIdsFromDb( |
| new SourceControlContext(instanceOwner), |
| ); |
|
|
| expect(new Set(versions.map((v) => v.id))).toEqual( |
| new Set([ |
| ...teamAWorkflows.map((w) => w.id), |
| ...teamBWorkflows.map((w) => w.id), |
| ...instanceOwnerWorkflows.map((w) => w.id), |
| ...projectAdminWorkflows.map((w) => w.id), |
| ...projectMemberWorkflows.map((w) => w.id), |
| ]), |
| ); |
| }); |
| }); |
|
|
| describe('if user is a project admin of a team project', () => { |
| it('should only get all available workflows from the team project', async () => { |
| let versions = await service.getLocalVersionIdsFromDb( |
| new SourceControlContext(projectAdmin), |
| ); |
|
|
| expect(new Set(versions.map((v) => v.id))).toEqual( |
| new Set([...teamAWorkflows.map((w) => w.id)]), |
| ); |
| }); |
| }); |
|
|
| describe('if user is a project member of a team project', () => { |
| it('should not get any workflows', async () => { |
| let versions = await service.getLocalVersionIdsFromDb( |
| new SourceControlContext(projectMember), |
| ); |
|
|
| expect(versions).toBeEmptyArray(); |
| }); |
| }); |
| }); |
|
|
| describe('getRemoteCredentialsFromFiles()', () => { |
| const mockCredential1File = '/mock/credential1.json'; |
| const mockCredential2File = '/mock/credential2.json'; |
| const mockCredential3File = '/mock/credential3.json'; |
| const mockCredential4File = '/mock/credential4.json'; |
| const mockCredential5File = '/mock/credential5.json'; |
|
|
| const mockCredential1Data: Partial<ExportableCredential> = { |
| id: 'credentials1', |
| name: 'Test Workflow', |
| ownedBy: { |
| type: 'personal', |
| personalEmail: 'someuser@example.com', |
| }, |
| }; |
| const mockCredential2Data: Partial<ExportableCredential> = { |
| id: 'credentials2', |
| name: 'Test Workflow', |
| ownedBy: { |
| type: 'team', |
| teamId: 'team1', |
| teamName: 'Team 1', |
| }, |
| }; |
| const mockCredential3Data: Partial<ExportableCredential> = { |
| id: 'credentials3', |
| name: 'Test Workflow', |
| ownedBy: { |
| type: 'team', |
| teamId: 'team2', |
| teamName: 'Team 2', |
| }, |
| }; |
| const mockCredential4Data: Partial<ExportableCredential> = { |
| id: 'credentials4', |
| name: 'Test Workflow', |
| ownedBy: { |
| type: 'personal', |
| personalEmail: 'someotheruser@example.com', |
| }, |
| }; |
| const mockCredential5Data: Partial<ExportableCredential> = { |
| id: 'credentials5', |
| name: 'Test Workflow', |
| ownedBy: { |
| type: 'team', |
| teamId: 'team1', |
| teamName: 'Team 1', |
| }, |
| }; |
|
|
| const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>; |
| const fsReadFile = jest.spyOn(fsp, 'readFile'); |
|
|
| let globalAdmin: User; |
| let globalOwner: User; |
| let globalMember: User; |
| let teamAdmin: User; |
| let team1: Project; |
|
|
| beforeAll(async () => { |
| [globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([ |
| createAdmin(), |
| createOwner(), |
| createMember(), |
| createMember(), |
| ]); |
|
|
| team1 = await createTeamProject('Team 1', teamAdmin); |
| }); |
|
|
| beforeEach(async () => { |
| globMock.mockImplementation(async () => [ |
| mockCredential1File, |
| mockCredential2File, |
| mockCredential3File, |
| mockCredential4File, |
| mockCredential5File, |
| ]); |
|
|
| fsReadFile.mockImplementation(async (path) => { |
| switch (path) { |
| case mockCredential1File: |
| return JSON.stringify({ |
| ...mockCredential1Data, |
| ownedBy: { |
| type: 'personal', |
| personalEmail: teamAdmin.email, |
| }, |
| }); |
| case mockCredential2File: |
| return JSON.stringify({ |
| ...mockCredential2Data, |
| ownedBy: { |
| type: 'team', |
| teamId: team1.id, |
| teamName: team1.name, |
| }, |
| }); |
| case mockCredential3File: |
| return JSON.stringify(mockCredential3Data); |
| case mockCredential4File: |
| return JSON.stringify(mockCredential4Data); |
| case mockCredential5File: |
| return JSON.stringify({ |
| ...mockCredential5Data, |
| ownedBy: { |
| type: 'team', |
| teamId: team1.id, |
| teamName: team1.name, |
| }, |
| }); |
| } |
| throw new Error(`Trying to access invalid file in test: ${path}`); |
| }); |
| }); |
|
|
| it('should show all remote credentials for instance admins', async () => { |
| const result = await service.getRemoteCredentialsFromFiles( |
| new SourceControlContext(globalAdmin), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set( |
| [ |
| mockCredential1Data, |
| mockCredential2Data, |
| mockCredential3Data, |
| mockCredential4Data, |
| mockCredential5Data, |
| ].map((r) => r.id), |
| ), |
| ); |
| }); |
|
|
| it('should show all remote credentials for instance owners', async () => { |
| const result = await service.getRemoteCredentialsFromFiles( |
| new SourceControlContext(globalOwner), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set( |
| [ |
| mockCredential1Data, |
| mockCredential2Data, |
| mockCredential3Data, |
| mockCredential4Data, |
| mockCredential5Data, |
| ].map((r) => r.id), |
| ), |
| ); |
| }); |
|
|
| it('should return no remote credentials for instance members', async () => { |
| const result = await service.getRemoteCredentialsFromFiles( |
| new SourceControlContext(globalMember), |
| ); |
|
|
| expect(result).toBeEmptyArray(); |
| }); |
|
|
| it('should return only remote credentials that belong to team project', async () => { |
| const result = await service.getRemoteCredentialsFromFiles( |
| new SourceControlContext(teamAdmin), |
| ); |
|
|
| expect(new Set(result.map((r) => r.id))).toEqual( |
| new Set([mockCredential2Data, mockCredential5Data].map((r) => r.id)), |
| ); |
| }); |
| }); |
|
|
| describe('getLocalCredentialsFromDb', () => { |
| let instanceOwner: User; |
| let projectAdmin: User; |
| let projectMember: User; |
| let teamProjectA: Project; |
| let teamProjectB: Project; |
| let teamACredentials: CredentialsEntity[]; |
| let teamBCredentials: CredentialsEntity[]; |
|
|
| beforeEach(async () => { |
| [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ |
| getGlobalOwner(), |
| createMember(), |
| createMember(), |
| createTeamProject(), |
| createTeamProject(), |
| ]); |
|
|
| await linkUserToProject(projectAdmin, teamProjectA, 'project:admin'); |
| await linkUserToProject(projectMember, teamProjectA, 'project:editor'); |
| await linkUserToProject(projectAdmin, teamProjectB, 'project:editor'); |
| await linkUserToProject(projectMember, teamProjectB, 'project:editor'); |
|
|
| teamACredentials = await Promise.all([ |
| await createCredentials( |
| { |
| name: 'credential1', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectA, |
| ), |
| await createCredentials( |
| { |
| name: 'credential2', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectA, |
| ), |
| await createCredentials( |
| { |
| name: 'credential3', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectA, |
| ), |
| ]); |
|
|
| teamBCredentials = await Promise.all([ |
| await createCredentials( |
| { |
| name: 'credential4', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectB, |
| ), |
| await createCredentials( |
| { |
| name: 'credential5', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectB, |
| ), |
| await createCredentials( |
| { |
| name: 'credential6', |
| data: '', |
| type: 'test', |
| }, |
| teamProjectB, |
| ), |
| ]); |
| }); |
|
|
| it('should get all available credentials on the instance, for an instance owner', async () => { |
| let versions = await service.getLocalCredentialsFromDb( |
| new SourceControlContext(instanceOwner), |
| ); |
|
|
| expect(new Set(versions.map((v) => v.id))).toEqual( |
| new Set([...teamACredentials.map((w) => w.id), ...teamBCredentials.map((w) => w.id)]), |
| ); |
| }); |
|
|
| it('should only get all available credentials from the team project, for a project admin', async () => { |
| let versions = await service.getLocalCredentialsFromDb( |
| new SourceControlContext(projectAdmin), |
| ); |
|
|
| expect(new Set(versions.map((v) => v.id))).toEqual( |
| new Set([...teamACredentials.map((w) => w.id)]), |
| ); |
| }); |
|
|
| it('should not get any workflows, for a project member', async () => { |
| let versions = await service.getLocalCredentialsFromDb( |
| new SourceControlContext(projectMember), |
| ); |
|
|
| expect(versions).toBeEmptyArray(); |
| }); |
| }); |
|
|
| describe('getLocalFoldersAndMappingsFromDb()', () => { |
| let instanceOwner: User; |
| let projectAdmin: User; |
| let projectMember: User; |
| let teamProjectA: Project; |
| let teamProjectB: Project; |
| let foldersProjectA: Folder[]; |
| let foldersProjectB: Folder[]; |
|
|
| beforeAll(async () => { |
| [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ |
| getGlobalOwner(), |
| createMember(), |
| createMember(), |
| createTeamProject(), |
| createTeamProject(), |
| ]); |
|
|
| await linkUserToProject(projectAdmin, teamProjectA, 'project:admin'); |
| await linkUserToProject(projectMember, teamProjectA, 'project:editor'); |
| await linkUserToProject(projectAdmin, teamProjectB, 'project:editor'); |
| await linkUserToProject(projectMember, teamProjectB, 'project:editor'); |
|
|
| foldersProjectA = await Promise.all([ |
| await createFolder(teamProjectA, { |
| name: 'folder1', |
| }), |
| await createFolder(teamProjectA, { |
| name: 'folder2', |
| }), |
| await createFolder(teamProjectA, { |
| name: 'folder3', |
| }), |
| ]); |
|
|
| foldersProjectA.push( |
| await createFolder(teamProjectA, { |
| name: 'folder1.1', |
| parentFolder: foldersProjectA[0], |
| }), |
| ); |
|
|
| foldersProjectB = await Promise.all([ |
| await createFolder(teamProjectB, { |
| name: 'folder1', |
| }), |
| await createFolder(teamProjectB, { |
| name: 'folder2', |
| }), |
| await createFolder(teamProjectB, { |
| name: 'folder3', |
| }), |
| ]); |
| }); |
|
|
| it('should get all available folders on the instance, for an instance owner', async () => { |
| let folders = await service.getLocalFoldersAndMappingsFromDb( |
| new SourceControlContext(instanceOwner), |
| ); |
|
|
| expect(new Set(folders.folders.map((v) => v.id))).toEqual( |
| new Set([...foldersProjectA.map((w) => w.id), ...foldersProjectB.map((w) => w.id)]), |
| ); |
| }); |
|
|
| it('should only get all available folders from the team project, for a project admin', async () => { |
| let versions = await service.getLocalFoldersAndMappingsFromDb( |
| new SourceControlContext(projectAdmin), |
| ); |
|
|
| expect(new Set(versions.folders.map((v) => v.id))).toEqual( |
| new Set([...foldersProjectA.map((w) => w.id)]), |
| ); |
| }); |
|
|
| it('should not get any folders, for a project member', async () => { |
| let versions = await service.getLocalFoldersAndMappingsFromDb( |
| new SourceControlContext(projectMember), |
| ); |
|
|
| expect(versions.folders).toBeEmptyArray(); |
| }); |
| }); |
|
|
| describe('getRemoteTagsAndMappingsFromFile()', () => { |
| const mockTagsFile = '/mock/tags.json'; |
|
|
| const mockTagData: { |
| tags: Array<{ id: string; name: string }>; |
| mappings: Array<{ workflowId: string; tagId: string }>; |
| } = { |
| tags: [ |
| { |
| id: 'tag1', |
| name: 'Tag 1', |
| }, |
| { |
| id: 'tag2', |
| name: 'Tag 2', |
| }, |
| { |
| id: 'tag3', |
| name: 'Tag 3', |
| }, |
| ], |
| mappings: [ |
| { |
| tagId: 'tag1', |
| workflowId: 'wf1', |
| }, |
| { |
| tagId: 'tag2', |
| workflowId: 'wf2', |
| }, |
| { |
| tagId: 'tag3', |
| workflowId: 'wf3', |
| }, |
| { |
| tagId: 'tag1', |
| workflowId: 'wf4', |
| }, |
| { |
| tagId: 'tag2', |
| workflowId: 'wf5', |
| }, |
| ], |
| }; |
|
|
| const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>; |
| const fsReadFile = jest.spyOn(fsp, 'readFile'); |
|
|
| let globalAdmin: User; |
| let globalOwner: User; |
| let globalMember: User; |
| let teamAdmin: User; |
| let team1: Project; |
| let team2: Project; |
| let workflowTeam1: WorkflowEntity[]; |
|
|
| beforeEach(async () => { |
| [globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([ |
| createAdmin(), |
| createOwner(), |
| createMember(), |
| createMember(), |
| ]); |
|
|
| globMock.mockResolvedValue([mockTagsFile]); |
|
|
| fsReadFile.mockResolvedValue(JSON.stringify(mockTagData)); |
|
|
| [team1, team2] = await Promise.all([ |
| await createTeamProject('Team 1', teamAdmin), |
| await createTeamProject('Team 2'), |
| ]); |
|
|
| workflowTeam1 = await Promise.all([ |
| await createWorkflow( |
| { |
| id: 'wf1', |
| name: 'Workflow 1', |
| }, |
| team1, |
| ), |
| await createWorkflow( |
| { |
| id: 'wf2', |
| name: 'Workflow 2', |
| }, |
| team1, |
| ), |
| await createWorkflow( |
| { |
| id: 'wf3', |
| name: 'Workflow 3', |
| }, |
| team1, |
| ), |
| ]); |
|
|
| await Promise.all([ |
| await createWorkflow( |
| { |
| id: 'wf4', |
| name: 'Workflow 4', |
| }, |
| team2, |
| ), |
| await createWorkflow( |
| { |
| id: 'wf5', |
| name: 'Workflow 5', |
| }, |
| team2, |
| ), |
| await createWorkflow( |
| { |
| id: 'wf6', |
| name: 'Workflow 6', |
| }, |
| team2, |
| ), |
| ]); |
| }); |
|
|
| beforeEach(async () => {}); |
|
|
| it('should show all remote tags and all remote mappings for instance admins', async () => { |
| const result = await service.getRemoteTagsAndMappingsFromFile( |
| new SourceControlContext(globalAdmin), |
| ); |
|
|
| expect(new Set(result.tags.map((r) => r.id))).toEqual( |
| new Set(mockTagData.tags.map((t) => t.id)), |
| ); |
| expect(new Set(result.mappings)).toEqual(new Set(mockTagData.mappings)); |
| }); |
|
|
| it('should show all remote tags and all remote mappings for instance owners', async () => { |
| const result = await service.getRemoteTagsAndMappingsFromFile( |
| new SourceControlContext(globalOwner), |
| ); |
|
|
| expect(new Set(result.tags.map((r) => r.id))).toEqual( |
| new Set(mockTagData.tags.map((t) => t.id)), |
| ); |
| expect(new Set(result.mappings)).toEqual(new Set(mockTagData.mappings)); |
| }); |
|
|
| it('should return all remote tags and no remote mappings for instance members', async () => { |
| const result = await service.getRemoteTagsAndMappingsFromFile( |
| new SourceControlContext(globalMember), |
| ); |
|
|
| expect(new Set(result.tags.map((r) => r.id))).toEqual( |
| new Set(mockTagData.tags.map((t) => t.id)), |
| ); |
| expect(result.mappings).toBeEmptyArray(); |
| }); |
|
|
| it('should return all remote tags and only remote mappings for in scope team for team admin', async () => { |
| const result = await service.getRemoteTagsAndMappingsFromFile( |
| new SourceControlContext(teamAdmin), |
| ); |
|
|
| expect(new Set(result.tags.map((r) => r.id))).toEqual( |
| new Set(mockTagData.tags.map((t) => t.id)), |
| ); |
| expect(new Set(result.mappings)).toEqual( |
| new Set( |
| mockTagData.mappings.filter((mapping) => |
| workflowTeam1.some((wf) => wf.id === mapping.workflowId), |
| ), |
| ), |
| ); |
| }); |
| }); |
|
|
| describe('getLocalTagsAndMappingsFromDb()', () => { |
| let instanceOwner: User; |
| let projectAdmin: User; |
| let projectMember: User; |
| let teamProjectA: Project; |
| let teamProjectB: Project; |
| let tags: TagEntity[]; |
| let workflowsProjectA: WorkflowEntity[]; |
| let workflowsProjectB: WorkflowEntity[]; |
| let mappings: Array<[TagEntity, WorkflowEntity]>; |
|
|
| beforeAll(async () => { |
| [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ |
| getGlobalOwner(), |
| createMember(), |
| createMember(), |
| createTeamProject(), |
| createTeamProject(), |
| ]); |
|
|
| await linkUserToProject(projectAdmin, teamProjectA, 'project:admin'); |
| await linkUserToProject(projectMember, teamProjectA, 'project:editor'); |
| await linkUserToProject(projectAdmin, teamProjectB, 'project:editor'); |
| await linkUserToProject(projectMember, teamProjectB, 'project:editor'); |
|
|
| tags = await Promise.all([ |
| await createTag({ |
| name: 'tag1', |
| }), |
| await createTag({ |
| name: 'tag2', |
| }), |
| await createTag({ |
| name: 'tag3', |
| }), |
| ]); |
|
|
| workflowsProjectA = await Promise.all([ |
| await createWorkflow( |
| { |
| id: 'workflow1', |
| name: 'Workflow 1', |
| }, |
| teamProjectA, |
| ), |
| await createWorkflow( |
| { |
| id: 'workflow2', |
| name: 'Workflow 2', |
| }, |
| teamProjectA, |
| ), |
| await createWorkflow( |
| { |
| id: 'workflow3', |
| name: 'Workflow 3', |
| }, |
| teamProjectA, |
| ), |
| ]); |
|
|
| workflowsProjectB = await Promise.all([ |
| await createWorkflow( |
| { |
| id: 'workflow4', |
| name: 'Workflow 4', |
| }, |
| teamProjectB, |
| ), |
| await createWorkflow( |
| { |
| id: 'workflow5', |
| name: 'Workflow 5', |
| }, |
| teamProjectB, |
| ), |
| await createWorkflow( |
| { |
| id: 'workflow6', |
| name: 'Workflow 6', |
| }, |
| teamProjectB, |
| ), |
| ]); |
|
|
| mappings = [ |
| [tags[0], workflowsProjectA[0]], |
| [tags[1], workflowsProjectA[0]], |
| [tags[0], workflowsProjectA[1]], |
| [tags[0], workflowsProjectB[0]], |
| [tags[1], workflowsProjectB[1]], |
| [tags[2], workflowsProjectB[2]], |
| ]; |
|
|
| await Promise.all( |
| mappings.map(async ([tag, workflow]) => await assignTagToWorkflow(tag, workflow)), |
| ); |
| }); |
|
|
| it('should get all available tags and mappings on the instance, for an instance owner', async () => { |
| let result = await service.getLocalTagsAndMappingsFromDb( |
| new SourceControlContext(instanceOwner), |
| ); |
|
|
| expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)])); |
| expect( |
| new Set( |
| result.mappings.map((m) => { |
| return [m.tagId, m.workflowId]; |
| }), |
| ), |
| ).toEqual( |
| new Set( |
| mappings.map(([tag, workflow]) => { |
| return [tag.id, workflow.id]; |
| }), |
| ), |
| ); |
| }); |
|
|
| it('should only get all available tags and only mappings from the team project, for a project admin', async () => { |
| let result = await service.getLocalTagsAndMappingsFromDb( |
| new SourceControlContext(projectAdmin), |
| ); |
|
|
| expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)])); |
|
|
| expect( |
| new Set( |
| result.mappings.map((m) => { |
| return [m.tagId, m.workflowId]; |
| }), |
| ), |
| ).toEqual( |
| new Set( |
| mappings |
| .filter((w) => workflowsProjectA.includes(w[1])) |
| .map(([tag, workflow]) => { |
| return [tag.id, workflow.id]; |
| }), |
| ), |
| ); |
| }); |
|
|
| it('should get all available tags but no mappings, for a project member', async () => { |
| let result = await service.getLocalTagsAndMappingsFromDb( |
| new SourceControlContext(projectMember), |
| ); |
|
|
| expect(new Set(result.tags.map((v) => v.id))).toEqual(new Set([...tags.map((w) => w.id)])); |
|
|
| expect(result.mappings).toBeEmptyArray(); |
| }); |
| }); |
|
|
| describe('importCredentialsFromWorkFolder()', () => { |
| describe('if user email specified by `ownedBy` exists at target instance', () => { |
| it('should assign credential ownership to original user', async () => { |
| const [importingUser, member] = await Promise.all([getGlobalOwner(), createMember()]); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: member.email, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const personalProject = await getPersonalProject(member); |
|
|
| const sharing = await sharedCredentialsRepository.findOneBy({ |
| credentialsId: CREDENTIAL_ID, |
| projectId: personalProject.id, |
| role: 'credential:owner', |
| }); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
| }); |
|
|
| describe('if user email specified by `ownedBy` is `null`', () => { |
| it('should assign credential ownership to importing user', async () => { |
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: null, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const personalProject = await getPersonalProject(importingUser); |
|
|
| const sharing = await sharedCredentialsRepository.findOneBy({ |
| credentialsId: CREDENTIAL_ID, |
| projectId: personalProject.id, |
| role: 'credential:owner', |
| }); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
| }); |
|
|
| describe('if user email specified by `ownedBy` does not exist at target instance', () => { |
| it('should assign credential ownership to importing user', async () => { |
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: 'user@test.com', |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const personalProject = await getPersonalProject(importingUser); |
|
|
| const sharing = await sharedCredentialsRepository.findOneBy({ |
| credentialsId: CREDENTIAL_ID, |
| projectId: personalProject.id, |
| role: 'credential:owner', |
| }); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
| }); |
| }); |
|
|
| describe('if owner specified by `ownedBy` does not exist at target instance', () => { |
| it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => { |
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: { |
| type: 'personal', |
| personalEmail: 'test@example.com', |
| }, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const personalProject = await getPersonalProject(importingUser); |
|
|
| const sharing = await sharedCredentialsRepository.findOneBy({ |
| credentialsId: CREDENTIAL_ID, |
| projectId: personalProject.id, |
| role: 'credential:owner', |
| }); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
|
|
| it('should create a new team project if the credential was owned by a team project in the source instance', async () => { |
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: { |
| type: 'team', |
| teamId: '1234-asdf', |
| teamName: 'Marketing', |
| }, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| { |
| const project = await projectRepository.findOne({ |
| where: [ |
| { |
| id: '1234-asdf', |
| }, |
| { name: 'Marketing' }, |
| ], |
| }); |
|
|
| expect(project?.id).not.toBe('1234-asdf'); |
| expect(project?.name).not.toBe('Marketing'); |
| } |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const sharing = await sharedCredentialsRepository.findOne({ |
| where: { |
| credentialsId: CREDENTIAL_ID, |
| role: 'credential:owner', |
| }, |
| relations: { project: true }, |
| }); |
|
|
| expect(sharing?.project.id).toBe('1234-asdf'); |
| expect(sharing?.project.name).toBe('Marketing'); |
| expect(sharing?.project.type).toBe('team'); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
| }); |
|
|
| describe('if owner specified by `ownedBy` does exist at target instance', () => { |
| it('should use the existing team project if credential owning project is found', async () => { |
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const CREDENTIAL_ID = nanoid(); |
|
|
| const project = await createTeamProject('Sales'); |
|
|
| const stub: ExportableCredential = { |
| id: CREDENTIAL_ID, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: { |
| type: 'team', |
| teamId: project.id, |
| teamName: 'Sales', |
| }, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: CREDENTIAL_ID })], |
| importingUser.id, |
| ); |
|
|
| const sharing = await sharedCredentialsRepository.findOneBy({ |
| credentialsId: CREDENTIAL_ID, |
| projectId: project.id, |
| role: 'credential:owner', |
| }); |
|
|
| expect(sharing).toBeTruthy(); |
| }); |
|
|
| it('should not change the owner if the credential is owned by somebody else on the target instance', async () => { |
| cipher.encrypt.mockReturnValue('some-encrypted-data'); |
|
|
| const importingUser = await getGlobalOwner(); |
|
|
| fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); |
|
|
| const targetProject = await createTeamProject('Marketing'); |
| const credential = await saveCredential(randomCredentialPayload(), { |
| project: targetProject, |
| role: 'credential:owner', |
| }); |
|
|
| const sourceProjectId = nanoid(); |
|
|
| const stub: ExportableCredential = { |
| id: credential.id, |
| name: 'My Credential', |
| type: 'someCredentialType', |
| data: {}, |
| ownedBy: { |
| type: 'team', |
| teamId: sourceProjectId, |
| teamName: 'Sales', |
| }, |
| }; |
|
|
| jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); |
|
|
| await service.importCredentialsFromWorkFolder( |
| [mock<SourceControlledFile>({ id: credential.id })], |
| importingUser.id, |
| ); |
|
|
| await expect( |
| sharedCredentialsRepository.findBy({ |
| credentialsId: credential.id, |
| }), |
| ).resolves.toMatchObject([ |
| { |
| projectId: targetProject.id, |
| role: 'credential:owner', |
| }, |
| ]); |
| await expect( |
| credentialsRepository.findBy({ |
| id: credential.id, |
| }), |
| ).resolves.toMatchObject([ |
| { |
| name: stub.name, |
| type: stub.type, |
| data: 'some-encrypted-data', |
| }, |
| ]); |
| }); |
| }); |
| }); |
|
|