mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(core): Scope getStatus for environments for project admin role (#15404)
This commit is contained in:
@@ -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<WorkflowRepository>();
|
||||
const folderRepository = mock<FolderRepository>();
|
||||
const projectRepository = mock<ProjectRepository>();
|
||||
const sourceControlScopedService = mock<SourceControlScopedService>();
|
||||
const service = new SourceControlImportService(
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
projectRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
@@ -33,6 +49,7 @@ describe('SourceControlImportService', () => {
|
||||
mock(),
|
||||
folderRepository,
|
||||
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
|
||||
sourceControlScopedService,
|
||||
);
|
||||
|
||||
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, 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
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('SourceControlService', () => {
|
||||
it('conflict depends on the value of `direction`', async () => {
|
||||
// ARRANGE
|
||||
const user = mock<User>();
|
||||
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.
|
||||
|
||||
@@ -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<SourceControlWorkflowVersionId[]> {
|
||||
async getRemoteVersionIdsFromFiles(
|
||||
context: SourceControlContext,
|
||||
): Promise<SourceControlWorkflowVersionId[]> {
|
||||
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<IWorkflowToImport>(await fsReadFile(file, { encoding: 'utf8' }));
|
||||
return jsonParse<IWorkflowToImport>(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<SourceControlWorkflowVersionId[]> {
|
||||
async getAllLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
|
||||
const localWorkflows = await this.workflowRepository.find({
|
||||
relations: ['parentFolder'],
|
||||
select: {
|
||||
@@ -147,36 +171,105 @@ export class SourceControlImportService {
|
||||
}) as SourceControlWorkflowVersionId[];
|
||||
}
|
||||
|
||||
async getRemoteCredentialsFromFiles(): Promise<
|
||||
Array<ExportableCredential & { filename: string }>
|
||||
> {
|
||||
async getLocalVersionIdsFromDb(
|
||||
context: SourceControlContext,
|
||||
): Promise<SourceControlWorkflowVersionId[]> {
|
||||
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<Array<ExportableCredential & { filename: string }>> {
|
||||
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<ExportableCredential>(
|
||||
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<Array<ExportableCredential & { filename: string }>> {
|
||||
async getLocalCredentialsFromDb(
|
||||
context: SourceControlContext,
|
||||
): Promise<Array<ExportableCredential & { filename: string }>> {
|
||||
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<ExportableTags> {
|
||||
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<ExportableTags>(
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<Project[] | undefined> {
|
||||
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<WorkflowEntity[] | undefined> {
|
||||
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<Project> | 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<Folder> | 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<WorkflowEntity> | 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<CredentialsEntity> | 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<WorkflowTagMapping> | 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { TagEntity, WorkflowTagMapping } from '@n8n/db';
|
||||
|
||||
export type ExportableTags = { tags: TagEntity[]; mappings: WorkflowTagMapping[] };
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,16 @@ export interface IWorkflowResponse extends IWorkflowBase {
|
||||
|
||||
export interface IWorkflowToImport
|
||||
extends Omit<IWorkflowBase, 'staticData' | 'pinData' | 'createdAt' | 'updatedAt'> {
|
||||
owner: {
|
||||
type: 'personal';
|
||||
personalEmail: string;
|
||||
};
|
||||
owner:
|
||||
| {
|
||||
type: 'personal';
|
||||
personalEmail: string;
|
||||
}
|
||||
| {
|
||||
type: 'team';
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
};
|
||||
parentFolderId: string | null;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import * as utils from '../shared/utils';
|
||||
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
let owner: User;
|
||||
|
||||
mockInstance(Telemetry);
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
let movedOutOfScopeWorkflow: WorkflowEntity;
|
||||
let movedIntoScopeWorkflow: WorkflowEntity;
|
||||
|
||||
let deletedOutOfScopeWorkflow: WorkflowEntity;
|
||||
let deletedInScopeWorkflow: WorkflowEntity;
|
||||
|
||||
let movedOutOfScopeCredential: CredentialsEntity;
|
||||
let movedIntoScopeCredential: CredentialsEntity;
|
||||
|
||||
let deletedOutOfScopeCredential: CredentialsEntity;
|
||||
let deletedInScopeCredential: CredentialsEntity;
|
||||
|
||||
let service: SourceControlService;
|
||||
|
||||
const globMock = fastGlob.default as unknown as jest.Mock<
|
||||
Promise<string[]>,
|
||||
[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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user