diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index fd104865b3..2c2fd11f47 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -1,4 +1,4 @@ -import type { WorkflowEntity } from '@n8n/db'; +import { Project, type ProjectRepository, User, WorkflowEntity } from '@n8n/db'; import type { FolderRepository } from '@n8n/db'; import type { WorkflowRepository } from '@n8n/db'; import * as fastGlob from 'fast-glob'; @@ -7,20 +7,36 @@ import { type InstanceSettings } from 'n8n-core'; import fsp from 'node:fs/promises'; import { SourceControlImportService } from '../source-control-import.service.ee'; +import type { SourceControlScopedService } from '../source-control-scoped.service'; import type { ExportableFolder } from '../types/exportable-folders'; +import { SourceControlContext } from '../types/source-control-context'; jest.mock('fast-glob'); +const globalAdminContext = new SourceControlContext( + Object.assign(new User(), { + role: 'global:admin', + }), +); + +const globalMemberContext = new SourceControlContext( + Object.assign(new User(), { + role: 'global:member', + }), +); + describe('SourceControlImportService', () => { const workflowRepository = mock(); const folderRepository = mock(); + const projectRepository = mock(); + const sourceControlScopedService = mock(); const service = new SourceControlImportService( mock(), mock(), mock(), mock(), mock(), - mock(), + projectRepository, mock(), mock(), mock(), @@ -33,6 +49,7 @@ describe('SourceControlImportService', () => { mock(), folderRepository, mock({ n8nFolder: '/mock/n8n' }), + sourceControlScopedService, ); const globMock = fastGlob.default as unknown as jest.Mock, string[]>; @@ -53,7 +70,7 @@ describe('SourceControlImportService', () => { fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); - const result = await service.getRemoteVersionIdsFromFiles(); + const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext); expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' }); expect(result).toHaveLength(1); @@ -71,7 +88,7 @@ describe('SourceControlImportService', () => { fsReadFile.mockResolvedValue('{}'); - const result = await service.getRemoteVersionIdsFromFiles(); + const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext); expect(result).toHaveLength(0); }); @@ -89,7 +106,7 @@ describe('SourceControlImportService', () => { fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData)); - const result = await service.getRemoteCredentialsFromFiles(); + const result = await service.getRemoteCredentialsFromFiles(globalAdminContext); expect(result).toHaveLength(1); expect(result[0]).toEqual( @@ -105,7 +122,7 @@ describe('SourceControlImportService', () => { globMock.mockResolvedValue(['/mock/invalid.json']); fsReadFile.mockResolvedValue('{}'); - const result = await service.getRemoteCredentialsFromFiles(); + const result = await service.getRemoteCredentialsFromFiles(globalAdminContext); expect(result).toHaveLength(0); }); @@ -147,7 +164,7 @@ describe('SourceControlImportService', () => { fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData)); - const result = await service.getRemoteTagsAndMappingsFromFile(); + const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext); expect(result.tags).toEqual(mockTagsData.tags); expect(result.mappings).toEqual(mockTagsData.mappings); @@ -156,11 +173,39 @@ describe('SourceControlImportService', () => { it('should return empty tags and mappings if no file found', async () => { globMock.mockResolvedValue([]); - const result = await service.getRemoteTagsAndMappingsFromFile(); + const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext); expect(result.tags).toHaveLength(0); expect(result.mappings).toHaveLength(0); }); + + it('should return only folder that belong to a project that belongs to the user', async () => { + globMock.mockResolvedValue(['/mock/tags.json']); + + const mockTagsData = { + tags: [{ id: 'tag1', name: 'Tag 1' }], + mappings: [ + { workflowId: 'workflow1', tagId: 'tag1' }, + { workflowId: 'workflow2', tagId: 'tag1' }, + { workflowId: 'workflow3', tagId: 'tag1' }, + ], + }; + + workflowRepository.find.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + }), + Object.assign(new WorkflowEntity(), { + id: 'workflow3', + }), + ]); + fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData)); + + const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext); + + expect(result.tags).toEqual(mockTagsData.tags); + expect(result.mappings).toEqual(mockTagsData.mappings); + }); }); describe('getRemoteFoldersAndMappingsFromFile', () => { @@ -186,7 +231,7 @@ describe('SourceControlImportService', () => { fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData)); - const result = await service.getRemoteFoldersAndMappingsFromFile(); + const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext); expect(result.folders).toEqual(mockFoldersData.folders); }); @@ -194,10 +239,81 @@ describe('SourceControlImportService', () => { it('should return empty folders and mappings if no file found', async () => { globMock.mockResolvedValue([]); - const result = await service.getRemoteFoldersAndMappingsFromFile(); + const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext); expect(result.folders).toHaveLength(0); }); + + it('should return only folder that belong to a project that belongs to the user', async () => { + globMock.mockResolvedValue(['/mock/folders.json']); + + const now = new Date(); + + const foldersToFind: ExportableFolder[] = [ + { + id: 'folder1', + name: 'folder 1', + parentFolderId: null, + homeProjectId: 'project1', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + { + id: 'folder3', + name: 'folder 3', + parentFolderId: null, + homeProjectId: 'project1', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + { + id: 'folder4', + name: 'folder 3', + parentFolderId: null, + homeProjectId: 'project3', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + ]; + + const mockFoldersData: { + folders: ExportableFolder[]; + } = { + folders: [ + { + id: 'folder0', + name: 'folder 0', + parentFolderId: null, + homeProjectId: 'project0', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + ...foldersToFind, + { + id: 'folder2', + name: 'folder 2', + parentFolderId: null, + homeProjectId: 'project2', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + ], + }; + + sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([ + Object.assign(new Project(), { + id: 'project1', + }), + Object.assign(new Project(), { + id: 'project3', + }), + ]); + fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData)); + + const result = await service.getRemoteFoldersAndMappingsFromFile(globalMemberContext); + + expect(result.folders).toEqual(foldersToFind); + }); }); describe('getLocalVersionIdsFromDb', () => { @@ -215,7 +331,7 @@ describe('SourceControlImportService', () => { workflowRepository.find.mockResolvedValue(mockWorkflows); - const result = await service.getLocalVersionIdsFromDb(); + const result = await service.getLocalVersionIdsFromDb(globalAdminContext); expect(result[0].updatedAt).toBe(now.toISOString()); }); @@ -232,7 +348,7 @@ describe('SourceControlImportService', () => { // Act - const result = await service.getLocalFoldersAndMappingsFromDb(); + const result = await service.getLocalFoldersAndMappingsFromDb(globalAdminContext); // Assert diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 62346d6db1..6e84bc8776 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -141,6 +141,7 @@ describe('SourceControlService', () => { it('conflict depends on the value of `direction`', async () => { // ARRANGE const user = mock(); + user.role = 'global:admin'; // Define a credential that does only exist locally. // Pulling this would delete it so it should be marked as a conflict. diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index c41f0cf57f..0c6553a4b3 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -39,9 +39,12 @@ import { SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, } from './constants'; import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee'; +import { SourceControlScopedService } from './source-control-scoped.service'; import type { ExportableCredential } from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; +import type { ExportableTags } from './types/exportable-tags'; import type { ResourceOwner } from './types/resource-owner'; +import type { SourceControlContext } from './types/source-control-context'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; import { VariablesService } from '../variables/variables.service.ee'; @@ -72,6 +75,7 @@ export class SourceControlImportService { private readonly tagService: TagService, private readonly folderRepository: FolderRepository, instanceSettings: InstanceSettings, + private readonly sourceControlScopedService: SourceControlScopedService, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER); @@ -81,19 +85,41 @@ export class SourceControlImportService { ); } - async getRemoteVersionIdsFromFiles(): Promise { + async getRemoteVersionIdsFromFiles( + context: SourceControlContext, + ): Promise { const remoteWorkflowFiles = await glob('*.json', { cwd: this.workflowExportFolder, absolute: true, }); - const remoteWorkflowFilesParsed = await Promise.all( + + const accessibleProjects = + await this.sourceControlScopedService.getAdminProjectsFromContext(context); + + const remoteWorkflowsRead = await Promise.all( remoteWorkflowFiles.map(async (file) => { this.logger.debug(`Parsing workflow file ${file}`); - const remote = jsonParse(await fsReadFile(file, { encoding: 'utf8' })); + return jsonParse(await fsReadFile(file, { encoding: 'utf8' })); + }), + ); + const remoteWorkflowFilesParsed = remoteWorkflowsRead + .filter((remote) => { if (!remote?.id) { - return undefined; + return false; } + if (Array.isArray(accessibleProjects)) { + const owner = remote.owner; + // The workflow `remote` belongs not to a project, that the context has access to + return ( + typeof owner === 'object' && + owner?.type === 'team' && + accessibleProjects.some((project) => project.id === owner.teamId) + ); + } + return true; + }) + .map((remote) => { return { id: remote.id, versionId: remote.versionId, @@ -102,14 +128,12 @@ export class SourceControlImportService { remoteId: remote.id, filename: getWorkflowExportPath(remote.id, this.workflowExportFolder), } as SourceControlWorkflowVersionId; - }), - ); - return remoteWorkflowFilesParsed.filter( - (e): e is SourceControlWorkflowVersionId => e !== undefined, - ); + }); + + return remoteWorkflowFilesParsed; } - async getLocalVersionIdsFromDb(): Promise { + async getAllLocalVersionIdsFromDb(): Promise { const localWorkflows = await this.workflowRepository.find({ relations: ['parentFolder'], select: { @@ -147,36 +171,105 @@ export class SourceControlImportService { }) as SourceControlWorkflowVersionId[]; } - async getRemoteCredentialsFromFiles(): Promise< - Array - > { + async getLocalVersionIdsFromDb( + context: SourceControlContext, + ): Promise { + const localWorkflows = await this.workflowRepository.find({ + relations: { + parentFolder: true, + }, + select: { + id: true, + versionId: true, + name: true, + updatedAt: true, + parentFolder: { + id: true, + }, + }, + where: this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContextFilter(context), + }); + return localWorkflows.map((local) => { + let updatedAt: Date; + if (local.updatedAt instanceof Date) { + updatedAt = local.updatedAt; + } else { + this.errorReporter.warn('updatedAt is not a Date', { + extra: { + type: typeof local.updatedAt, + value: local.updatedAt, + }, + }); + updatedAt = isNaN(Date.parse(local.updatedAt)) ? new Date() : new Date(local.updatedAt); + } + return { + id: local.id, + versionId: local.versionId, + name: local.name, + localId: local.id, + parentFolderId: local.parentFolder?.id ?? null, + filename: getWorkflowExportPath(local.id, this.workflowExportFolder), + updatedAt: updatedAt.toISOString(), + }; + }) as SourceControlWorkflowVersionId[]; + } + + async getRemoteCredentialsFromFiles( + context: SourceControlContext, + ): Promise> { const remoteCredentialFiles = await glob('*.json', { cwd: this.credentialExportFolder, absolute: true, }); - const remoteCredentialFilesParsed = await Promise.all( + + const accessibleProjects = + await this.sourceControlScopedService.getAdminProjectsFromContext(context); + + const remoteCredentialFilesRead = await Promise.all( remoteCredentialFiles.map(async (file) => { this.logger.debug(`Parsing credential file ${file}`); const remote = jsonParse( await fsReadFile(file, { encoding: 'utf8' }), ); + return remote; + }), + ); + + const remoteCredentialFilesParsed = remoteCredentialFilesRead + .filter((remote) => { if (!remote?.id) { - return undefined; + return false; } + if (Array.isArray(accessibleProjects)) { + const owner = remote.ownedBy; + // The credential `remote` belongs not to a project, that the context has access to + return ( + typeof owner === 'object' && + owner?.type === 'team' && + accessibleProjects.some((project) => project.id === owner.teamId) + ); + } + return true; + }) + .map((remote) => { return { ...remote, filename: getCredentialExportPath(remote.id, this.credentialExportFolder), }; - }), - ); + }); + return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array< ExportableCredential & { filename: string } >; } - async getLocalCredentialsFromDb(): Promise> { + async getLocalCredentialsFromDb( + context: SourceControlContext, + ): Promise> { const localCredentials = await this.credentialsRepository.find({ select: ['id', 'name', 'type'], + where: + this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context), }); return localCredentials.map((local) => ({ id: local.id, @@ -204,7 +297,7 @@ export class SourceControlImportService { return await this.variablesService.getAllCached(); } - async getRemoteFoldersAndMappingsFromFile(): Promise<{ + async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{ folders: ExportableFolder[]; }> { const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, { @@ -218,12 +311,22 @@ export class SourceControlImportService { }>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), { fallbackValue: { folders: [] }, }); + + const accessibleProjects = + await this.sourceControlScopedService.getAdminProjectsFromContext(context); + + if (Array.isArray(accessibleProjects)) { + mappedFolders.folders = mappedFolders.folders.filter((folder) => + accessibleProjects.some((project) => project.id === folder.homeProjectId), + ); + } + return mappedFolders; } return { folders: [] }; } - async getLocalFoldersAndMappingsFromDb(): Promise<{ + async getLocalFoldersAndMappingsFromDb(context: SourceControlContext): Promise<{ folders: ExportableFolder[]; }> { const localFolders = await this.folderRepository.find({ @@ -236,6 +339,7 @@ export class SourceControlImportService { parentFolder: { id: true }, homeProject: { id: true }, }, + where: this.sourceControlScopedService.getFoldersInAdminProjectsFromContextFilter(context), }); return { @@ -250,26 +354,33 @@ export class SourceControlImportService { }; } - async getRemoteTagsAndMappingsFromFile(): Promise<{ - tags: TagEntity[]; - mappings: WorkflowTagMapping[]; - }> { + async getRemoteTagsAndMappingsFromFile(context: SourceControlContext): Promise { const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, { cwd: this.gitFolder, absolute: true, }); if (tagsFile.length > 0) { this.logger.debug(`Importing tags from file ${tagsFile[0]}`); - const mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>( + const mappedTags = jsonParse( await fsReadFile(tagsFile[0], { encoding: 'utf8' }), { fallbackValue: { tags: [], mappings: [] } }, ); + + const accessibleWorkflows = + await this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContext(context); + + if (accessibleWorkflows) { + mappedTags.mappings = mappedTags.mappings.filter((mapping) => + accessibleWorkflows.some((workflow) => workflow.id === mapping.workflowId), + ); + } + return mappedTags; } return { tags: [], mappings: [] }; } - async getLocalTagsAndMappingsFromDb(): Promise<{ + async getLocalTagsAndMappingsFromDb(context: SourceControlContext): Promise<{ tags: TagEntity[]; mappings: WorkflowTagMapping[]; }> { @@ -278,6 +389,10 @@ export class SourceControlImportService { }); const localMappings = await this.workflowTagMappingRepository.find({ select: ['workflowId', 'tagId'], + where: + this.sourceControlScopedService.getWorkflowTagMappingInAdminProjectsFromContextFilter( + context, + ), }); return { tags: localTags, mappings: localMappings }; } diff --git a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts new file mode 100644 index 0000000000..19283852df --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts @@ -0,0 +1,139 @@ +import { + type CredentialsEntity, + type Folder, + type Project, + ProjectRepository, + type WorkflowEntity, + WorkflowRepository, + type WorkflowTagMapping, +} from '@n8n/db'; +import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import type { FindOptionsWhere } from '@n8n/typeorm'; + +import type { SourceControlContext } from './types/source-control-context'; + +@Service() +export class SourceControlScopedService { + constructor( + private readonly projectRepository: ProjectRepository, + private readonly workflowRepository: WorkflowRepository, + ) {} + + async getAdminProjectsFromContext(context: SourceControlContext): Promise { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + return await this.projectRepository.find({ + relations: { + projectRelations: true, + }, + select: { + id: true, + name: true, + }, + where: this.getAdminProjectsByContextFilter(context), + }); + } + + async getWorkflowsInAdminProjectsFromContext( + context: SourceControlContext, + ): Promise { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + return await this.workflowRepository.find({ + select: { + id: true, + }, + where: this.getWorkflowsInAdminProjectsFromContextFilter(context), + }); + } + + getAdminProjectsByContextFilter( + context: SourceControlContext, + ): FindOptionsWhere | undefined { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + return { + type: 'team', + projectRelations: { + role: 'project:admin', + userId: context.user.id, + }, + }; + } + + getFoldersInAdminProjectsFromContextFilter( + context: SourceControlContext, + ): FindOptionsWhere | undefined { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + // We build a filter to only select folder, that belong to a team project + // that the user is an admin off + return { + homeProject: this.getAdminProjectsByContextFilter(context), + }; + } + + getWorkflowsInAdminProjectsFromContextFilter( + context: SourceControlContext, + ): FindOptionsWhere | undefined { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + // We build a filter to only select workflows, that belong to a team project + // that the user is an admin off + return { + shared: { + role: 'workflow:owner', + project: this.getAdminProjectsByContextFilter(context), + }, + }; + } + + getCredentialsInAdminProjectsFromContextFilter( + context: SourceControlContext, + ): FindOptionsWhere | undefined { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + // We build a filter to only select workflows, that belong to a team project + // that the user is an admin off + return { + shared: { + role: 'credential:owner', + project: this.getAdminProjectsByContextFilter(context), + }, + }; + } + + getWorkflowTagMappingInAdminProjectsFromContextFilter( + context: SourceControlContext, + ): FindOptionsWhere | undefined { + if (context.hasAccessToAllProjects()) { + // In case the user is a global admin or owner, we don't need a filter + return; + } + + // We build a filter to only select workflows, that belong to a team project + // that the user is an admin off + return { + workflows: this.getWorkflowsInAdminProjectsFromContextFilter(context), + }; + } +} diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index de4ecbe5b6..cc3b54a8d7 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -3,9 +3,15 @@ import type { PushWorkFolderRequestDto, SourceControlledFile, } from '@n8n/api-types'; -import type { Variables, TagEntity, User } from '@n8n/db'; -import { FolderRepository, TagRepository } from '@n8n/db'; +import { + type Variables, + type TagEntity, + FolderRepository, + TagRepository, + type User, +} from '@n8n/db'; import { Service } from '@n8n/di'; +import { hasGlobalScope } from '@n8n/permissions'; import { writeFileSync } from 'fs'; import { Logger } from 'n8n-core'; import { UnexpectedError, UserError } from 'n8n-workflow'; @@ -13,6 +19,7 @@ import path from 'path'; import type { PushResult } from 'simple-git'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import { @@ -38,6 +45,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se import type { ExportableCredential } from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; import type { ImportResult } from './types/import-result'; +import { SourceControlContext } from './types/source-control-context'; import type { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; @@ -481,6 +489,13 @@ export class SourceControlService { async getStatus(user: User, options: SourceControlGetStatus) { await this.sanityCheck(); + const context = new SourceControlContext(user); + + if (options.direction === 'pull' && !hasGlobalScope(user, 'sourceControl:pull')) { + // A pull is only allowed by global admins or owners + return new ForbiddenError('You do not have permission to pull from source control'); + } + const sourceControlledFiles: SourceControlledFile[] = []; // fetch and reset hard first @@ -492,10 +507,10 @@ export class SourceControlService { wfMissingInLocal, wfMissingInRemote, wfModifiedInEither, - } = await this.getStatusWorkflows(options, sourceControlledFiles); + } = await this.getStatusWorkflows(options, context, sourceControlledFiles); const { credMissingInLocal, credMissingInRemote, credModifiedInEither } = - await this.getStatusCredentials(options, sourceControlledFiles); + await this.getStatusCredentials(options, context, sourceControlledFiles); const { varMissingInLocal, varMissingInRemote, varModifiedInEither } = await this.getStatusVariables(options, sourceControlledFiles); @@ -506,10 +521,10 @@ export class SourceControlService { tagsModifiedInEither, mappingsMissingInLocal, mappingsMissingInRemote, - } = await this.getStatusTagsMappings(options, sourceControlledFiles); + } = await this.getStatusTagsMappings(options, context, sourceControlledFiles); const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } = - await this.getStatusFoldersMapping(options, sourceControlledFiles); + await this.getStatusFoldersMapping(options, context, sourceControlledFiles); // #region Tracking Information if (options.direction === 'push') { @@ -555,14 +570,34 @@ export class SourceControlService { private async getStatusWorkflows( options: SourceControlGetStatus, + context: SourceControlContext, sourceControlledFiles: SourceControlledFile[], ) { - const wfRemoteVersionIds = await this.sourceControlImportService.getRemoteVersionIdsFromFiles(); - const wfLocalVersionIds = await this.sourceControlImportService.getLocalVersionIdsFromDb(); + // TODO: We need to check the case where it exists in the DB (out of scope) but is in GIT + const wfRemoteVersionIds = + await this.sourceControlImportService.getRemoteVersionIdsFromFiles(context); + const wfLocalVersionIds = + await this.sourceControlImportService.getLocalVersionIdsFromDb(context); - const wfMissingInLocal = wfRemoteVersionIds.filter( - (remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1, - ); + let outOfScopeWF: SourceControlWorkflowVersionId[] = []; + + if (!context.hasAccessToAllProjects()) { + // we need to query for all wf in the DB to hide possible deletions, + // when a wf went out of scope locally + outOfScopeWF = await this.sourceControlImportService.getAllLocalVersionIdsFromDb(); + outOfScopeWF = outOfScopeWF.filter( + (wf) => !wfLocalVersionIds.some((local) => local.id === wf.id), + ); + } + + const wfMissingInLocal = wfRemoteVersionIds + .filter((remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1) + .filter( + // If we have out of scope workflows, these are workflows, that are not + // visible locally, but exists locally but are available in remote + // we skip them and hide them from deletion from the user. + (remote) => !outOfScopeWF.some((outOfScope) => outOfScope.id === remote.id), + ); const wfMissingInRemote = wfLocalVersionIds.filter( (local) => wfRemoteVersionIds.findIndex((remote) => remote.id === local.id) === -1, @@ -654,10 +689,12 @@ export class SourceControlService { private async getStatusCredentials( options: SourceControlGetStatus, + context: SourceControlContext, sourceControlledFiles: SourceControlledFile[], ) { - const credRemoteIds = await this.sourceControlImportService.getRemoteCredentialsFromFiles(); - const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(); + const credRemoteIds = + await this.sourceControlImportService.getRemoteCredentialsFromFiles(context); + const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(context); const credMissingInLocal = credRemoteIds.filter( (remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1, @@ -807,6 +844,7 @@ export class SourceControlService { private async getStatusTagsMappings( options: SourceControlGetStatus, + context: SourceControlContext, sourceControlledFiles: SourceControlledFile[], ) { const lastUpdatedTag = await this.tagRepository.find({ @@ -816,8 +854,9 @@ export class SourceControlService { }); const tagMappingsRemote = - await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(); - const tagMappingsLocal = await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(); + await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context); + const tagMappingsLocal = + await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(context); const tagsMissingInLocal = tagMappingsRemote.tags.filter( (remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1, @@ -901,6 +940,7 @@ export class SourceControlService { private async getStatusFoldersMapping( options: SourceControlGetStatus, + context: SourceControlContext, sourceControlledFiles: SourceControlledFile[], ) { const lastUpdatedFolder = await this.folderRepository.find({ @@ -910,9 +950,9 @@ export class SourceControlService { }); const foldersMappingsRemote = - await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(); + await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(context); const foldersMappingsLocal = - await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(); + await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(context); const foldersMissingInLocal = foldersMappingsRemote.folders.filter( (remote) => foldersMappingsLocal.folders.findIndex((local) => local.id === remote.id) === -1, diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-tags.ts b/packages/cli/src/environments.ee/source-control/types/exportable-tags.ts new file mode 100644 index 0000000000..b0212863f7 --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/types/exportable-tags.ts @@ -0,0 +1,3 @@ +import type { TagEntity, WorkflowTagMapping } from '@n8n/db'; + +export type ExportableTags = { tags: TagEntity[]; mappings: WorkflowTagMapping[] }; diff --git a/packages/cli/src/environments.ee/source-control/types/source-control-context.ts b/packages/cli/src/environments.ee/source-control/types/source-control-context.ts new file mode 100644 index 0000000000..604ad4147c --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/types/source-control-context.ts @@ -0,0 +1,14 @@ +import type { User } from '@n8n/db'; +import { hasGlobalScope } from '@n8n/permissions'; + +export class SourceControlContext { + constructor(private readonly userInternal: User) {} + + get user() { + return this.userInternal; + } + + hasAccessToAllProjects() { + return hasGlobalScope(this.userInternal, 'project:update'); + } +} diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 5ae61d5222..1879950f4e 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -48,10 +48,16 @@ export interface IWorkflowResponse extends IWorkflowBase { export interface IWorkflowToImport extends Omit { - owner: { - type: 'personal'; - personalEmail: string; - }; + owner: + | { + type: 'personal'; + personalEmail: string; + } + | { + type: 'team'; + teamId: string; + teamName: string; + }; parentFolderId: string | null; } diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 88ad5e178b..a02ea93ea1 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -1,10 +1,22 @@ import type { SourceControlledFile } from '@n8n/api-types'; -import { CredentialsRepository } from '@n8n/db'; +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'; @@ -13,15 +25,23 @@ 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 { saveCredential } from '../shared/db/credentials'; -import { createTeamProject, getPersonalProject } from '../shared/db/projects'; -import { createMember, getGlobalOwner } from '../shared/db/users'; +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; @@ -29,6 +49,10 @@ describe('SourceControlImportService', () => { 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); @@ -40,6 +64,10 @@ describe('SourceControlImportService', () => { 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(), @@ -47,18 +75,19 @@ describe('SourceControlImportService', () => { mock(), credentialsRepository, projectRepository, - mock(), + tagRepository, mock(), sharedCredentialsRepository, userRepository, mock(), - mock(), - mock(), + workflowRepository, + workflowTagMappingRepository, mock(), mock(), mock(), folderRepository, mock({ n8nFolder: '/some-path' }), + sourceControlScopedService, ); }); @@ -72,6 +101,974 @@ describe('SourceControlImportService', () => { 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 = { + id: 'workflow1', + versionId: 'v1', + name: 'Test Workflow', + owner: { + type: 'personal', + personalEmail: 'someuser@example.com', + }, + }; + const mockWorkflow2Data: Partial = { + id: 'workflow2', + versionId: 'v1', + name: 'Test Workflow', + owner: { + type: 'team', + teamId: 'team1', + teamName: 'Team 1', + }, + }; + const mockWorkflow3Data: Partial = { + id: 'workflow3', + versionId: 'v1', + name: 'Test Workflow', + owner: { + type: 'team', + teamId: 'team2', + teamName: 'Team 2', + }, + }; + const mockWorkflow4Data: Partial = { + id: 'workflow4', + versionId: 'v1', + name: 'Test Workflow', + owner: { + type: 'personal', + personalEmail: 'someotheruser@example.com', + }, + }; + const mockWorkflow5Data: Partial = { + id: 'workflow5', + versionId: 'v1', + name: 'Test Workflow', + owner: { + type: 'team', + teamId: 'team1', + teamName: 'Team 1', + }, + }; + + const globMock = fastGlob.default as unknown as jest.Mock, 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 = { + id: 'credentials1', + name: 'Test Workflow', + ownedBy: { + type: 'personal', + personalEmail: 'someuser@example.com', + }, + }; + const mockCredential2Data: Partial = { + id: 'credentials2', + name: 'Test Workflow', + ownedBy: { + type: 'team', + teamId: 'team1', + teamName: 'Team 1', + }, + }; + const mockCredential3Data: Partial = { + id: 'credentials3', + name: 'Test Workflow', + ownedBy: { + type: 'team', + teamId: 'team2', + teamName: 'Team 2', + }, + }; + const mockCredential4Data: Partial = { + id: 'credentials4', + name: 'Test Workflow', + ownedBy: { + type: 'personal', + personalEmail: 'someotheruser@example.com', + }, + }; + const mockCredential5Data: Partial = { + id: 'credentials5', + name: 'Test Workflow', + ownedBy: { + type: 'team', + teamId: 'team1', + teamName: 'Team 1', + }, + }; + + const globMock = fastGlob.default as unknown as jest.Mock, 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, 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 () => { diff --git a/packages/cli/test/integration/environments/source-control.api.test.ts b/packages/cli/test/integration/environments/source-control.api.test.ts index af41991fd7..7f2bd5197f 100644 --- a/packages/cli/test/integration/environments/source-control.api.test.ts +++ b/packages/cli/test/integration/environments/source-control.api.test.ts @@ -13,6 +13,7 @@ import * as utils from '../shared/utils'; let authOwnerAgent: SuperAgentTest; let owner: User; + mockInstance(Telemetry); const testServer = utils.setupTestServer({ diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts new file mode 100644 index 0000000000..613bd7cebc --- /dev/null +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -0,0 +1,777 @@ +import { + CredentialsEntity, + type Folder, + Project, + type TagEntity, + type User, + WorkflowEntity, +} from '@n8n/db'; +import { Container } from '@n8n/di'; +import * as fastGlob from 'fast-glob'; +import fsp from 'node:fs/promises'; + +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 { 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 { ResourceOwner } from '@/environments.ee/source-control/types/resource-owner'; +import { createCredentials } from '@test-integration/db/credentials'; +import { createFolder } from '@test-integration/db/folders'; +import { createTeamProject } from '@test-integration/db/projects'; +import { assignTagToWorkflow, createTag } 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: ResourceOwner; + + 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: ResourceOwner; + + 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', () => { + beforeAll(async () => { + await testDb.init(); + + sourceControlPreferencesService = Container.get(SourceControlPreferencesService); + await sourceControlPreferencesService.setPreferences({ + connected: true, + keyGeneratorType: 'rsa', + }); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getStatus', () => { + /* + 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; + + 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 service: SourceControlService; + + const globMock = fastGlob.default as unknown as jest.Mock< + Promise, + [fastGlob.Pattern | fastGlob.Pattern[], fastGlob.Options] + >; + const fsReadFile = jest.spyOn(fsp, 'readFile'); + + beforeAll(async () => { + /* + 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: '', + type: '', + }); + + deletedOutOfScopeCredential = Object.assign(new CredentialsEntity(), { + id: 'deletedOutOfScope', + name: 'deletedOutOfScope', + data: '', + type: '', + }); + + [ + movedOutOfScopeCredential, + movedIntoScopeCredential, + movedOutOfScopeWorkflow, + movedIntoScopeWorkflow, + ] = await Promise.all([ + await createCredentials( + { + name: 'OutOfScope', + data: '', + type: '', + }, + projectB, + ), + await createCredentials( + { + name: 'IntoScope', + data: '', + 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: '', + type: '', + }, + project, + ), + await createCredentials( + { + name: `${project.name}-CredB‚`, + data: '', + 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, + }; + + service = Container.get(SourceControlService); + + // 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) => { + return JSON.stringify(gitFiles[path]); + }); + }); + + 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]), + ); + }); + }); + }); + }); +});