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 { FolderRepository } from '@n8n/db';
|
||||||
import type { WorkflowRepository } from '@n8n/db';
|
import type { WorkflowRepository } from '@n8n/db';
|
||||||
import * as fastGlob from 'fast-glob';
|
import * as fastGlob from 'fast-glob';
|
||||||
@@ -7,20 +7,36 @@ import { type InstanceSettings } from 'n8n-core';
|
|||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
|
|
||||||
import { SourceControlImportService } from '../source-control-import.service.ee';
|
import { SourceControlImportService } from '../source-control-import.service.ee';
|
||||||
|
import type { SourceControlScopedService } from '../source-control-scoped.service';
|
||||||
import type { ExportableFolder } from '../types/exportable-folders';
|
import type { ExportableFolder } from '../types/exportable-folders';
|
||||||
|
import { SourceControlContext } from '../types/source-control-context';
|
||||||
|
|
||||||
jest.mock('fast-glob');
|
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', () => {
|
describe('SourceControlImportService', () => {
|
||||||
const workflowRepository = mock<WorkflowRepository>();
|
const workflowRepository = mock<WorkflowRepository>();
|
||||||
const folderRepository = mock<FolderRepository>();
|
const folderRepository = mock<FolderRepository>();
|
||||||
|
const projectRepository = mock<ProjectRepository>();
|
||||||
|
const sourceControlScopedService = mock<SourceControlScopedService>();
|
||||||
const service = new SourceControlImportService(
|
const service = new SourceControlImportService(
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
projectRepository,
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
@@ -33,6 +49,7 @@ describe('SourceControlImportService', () => {
|
|||||||
mock(),
|
mock(),
|
||||||
folderRepository,
|
folderRepository,
|
||||||
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
|
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
|
||||||
|
sourceControlScopedService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
|
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
|
||||||
@@ -53,7 +70,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
|
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
|
||||||
|
|
||||||
const result = await service.getRemoteVersionIdsFromFiles();
|
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
|
||||||
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
|
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
@@ -71,7 +88,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
fsReadFile.mockResolvedValue('{}');
|
fsReadFile.mockResolvedValue('{}');
|
||||||
|
|
||||||
const result = await service.getRemoteVersionIdsFromFiles();
|
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -89,7 +106,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
|
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
|
||||||
|
|
||||||
const result = await service.getRemoteCredentialsFromFiles();
|
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
@@ -105,7 +122,7 @@ describe('SourceControlImportService', () => {
|
|||||||
globMock.mockResolvedValue(['/mock/invalid.json']);
|
globMock.mockResolvedValue(['/mock/invalid.json']);
|
||||||
fsReadFile.mockResolvedValue('{}');
|
fsReadFile.mockResolvedValue('{}');
|
||||||
|
|
||||||
const result = await service.getRemoteCredentialsFromFiles();
|
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
|
||||||
|
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -147,7 +164,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
|
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
|
||||||
|
|
||||||
const result = await service.getRemoteTagsAndMappingsFromFile();
|
const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext);
|
||||||
|
|
||||||
expect(result.tags).toEqual(mockTagsData.tags);
|
expect(result.tags).toEqual(mockTagsData.tags);
|
||||||
expect(result.mappings).toEqual(mockTagsData.mappings);
|
expect(result.mappings).toEqual(mockTagsData.mappings);
|
||||||
@@ -156,11 +173,39 @@ describe('SourceControlImportService', () => {
|
|||||||
it('should return empty tags and mappings if no file found', async () => {
|
it('should return empty tags and mappings if no file found', async () => {
|
||||||
globMock.mockResolvedValue([]);
|
globMock.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.getRemoteTagsAndMappingsFromFile();
|
const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext);
|
||||||
|
|
||||||
expect(result.tags).toHaveLength(0);
|
expect(result.tags).toHaveLength(0);
|
||||||
expect(result.mappings).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', () => {
|
describe('getRemoteFoldersAndMappingsFromFile', () => {
|
||||||
@@ -186,7 +231,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
|
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
|
||||||
|
|
||||||
const result = await service.getRemoteFoldersAndMappingsFromFile();
|
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
|
||||||
|
|
||||||
expect(result.folders).toEqual(mockFoldersData.folders);
|
expect(result.folders).toEqual(mockFoldersData.folders);
|
||||||
});
|
});
|
||||||
@@ -194,10 +239,81 @@ describe('SourceControlImportService', () => {
|
|||||||
it('should return empty folders and mappings if no file found', async () => {
|
it('should return empty folders and mappings if no file found', async () => {
|
||||||
globMock.mockResolvedValue([]);
|
globMock.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.getRemoteFoldersAndMappingsFromFile();
|
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
|
||||||
|
|
||||||
expect(result.folders).toHaveLength(0);
|
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', () => {
|
describe('getLocalVersionIdsFromDb', () => {
|
||||||
@@ -215,7 +331,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
workflowRepository.find.mockResolvedValue(mockWorkflows);
|
workflowRepository.find.mockResolvedValue(mockWorkflows);
|
||||||
|
|
||||||
const result = await service.getLocalVersionIdsFromDb();
|
const result = await service.getLocalVersionIdsFromDb(globalAdminContext);
|
||||||
|
|
||||||
expect(result[0].updatedAt).toBe(now.toISOString());
|
expect(result[0].updatedAt).toBe(now.toISOString());
|
||||||
});
|
});
|
||||||
@@ -232,7 +348,7 @@ describe('SourceControlImportService', () => {
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
||||||
const result = await service.getLocalFoldersAndMappingsFromDb();
|
const result = await service.getLocalFoldersAndMappingsFromDb(globalAdminContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ describe('SourceControlService', () => {
|
|||||||
it('conflict depends on the value of `direction`', async () => {
|
it('conflict depends on the value of `direction`', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const user = mock<User>();
|
const user = mock<User>();
|
||||||
|
user.role = 'global:admin';
|
||||||
|
|
||||||
// Define a credential that does only exist locally.
|
// Define a credential that does only exist locally.
|
||||||
// Pulling this would delete it so it should be marked as a conflict.
|
// Pulling this would delete it so it should be marked as a conflict.
|
||||||
|
|||||||
@@ -39,9 +39,12 @@ import {
|
|||||||
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
|
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee';
|
import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee';
|
||||||
|
import { SourceControlScopedService } from './source-control-scoped.service';
|
||||||
import type { ExportableCredential } from './types/exportable-credential';
|
import type { ExportableCredential } from './types/exportable-credential';
|
||||||
import type { ExportableFolder } from './types/exportable-folders';
|
import type { ExportableFolder } from './types/exportable-folders';
|
||||||
|
import type { ExportableTags } from './types/exportable-tags';
|
||||||
import type { ResourceOwner } from './types/resource-owner';
|
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 type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
||||||
import { VariablesService } from '../variables/variables.service.ee';
|
import { VariablesService } from '../variables/variables.service.ee';
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ export class SourceControlImportService {
|
|||||||
private readonly tagService: TagService,
|
private readonly tagService: TagService,
|
||||||
private readonly folderRepository: FolderRepository,
|
private readonly folderRepository: FolderRepository,
|
||||||
instanceSettings: InstanceSettings,
|
instanceSettings: InstanceSettings,
|
||||||
|
private readonly sourceControlScopedService: SourceControlScopedService,
|
||||||
) {
|
) {
|
||||||
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
||||||
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_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', {
|
const remoteWorkflowFiles = await glob('*.json', {
|
||||||
cwd: this.workflowExportFolder,
|
cwd: this.workflowExportFolder,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
});
|
});
|
||||||
const remoteWorkflowFilesParsed = await Promise.all(
|
|
||||||
|
const accessibleProjects =
|
||||||
|
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
||||||
|
|
||||||
|
const remoteWorkflowsRead = await Promise.all(
|
||||||
remoteWorkflowFiles.map(async (file) => {
|
remoteWorkflowFiles.map(async (file) => {
|
||||||
this.logger.debug(`Parsing workflow file ${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) {
|
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 {
|
return {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
versionId: remote.versionId,
|
versionId: remote.versionId,
|
||||||
@@ -102,14 +128,12 @@ export class SourceControlImportService {
|
|||||||
remoteId: remote.id,
|
remoteId: remote.id,
|
||||||
filename: getWorkflowExportPath(remote.id, this.workflowExportFolder),
|
filename: getWorkflowExportPath(remote.id, this.workflowExportFolder),
|
||||||
} as SourceControlWorkflowVersionId;
|
} as SourceControlWorkflowVersionId;
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
return remoteWorkflowFilesParsed.filter(
|
return remoteWorkflowFilesParsed;
|
||||||
(e): e is SourceControlWorkflowVersionId => e !== undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
|
async getAllLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
|
||||||
const localWorkflows = await this.workflowRepository.find({
|
const localWorkflows = await this.workflowRepository.find({
|
||||||
relations: ['parentFolder'],
|
relations: ['parentFolder'],
|
||||||
select: {
|
select: {
|
||||||
@@ -147,36 +171,105 @@ export class SourceControlImportService {
|
|||||||
}) as SourceControlWorkflowVersionId[];
|
}) as SourceControlWorkflowVersionId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteCredentialsFromFiles(): Promise<
|
async getLocalVersionIdsFromDb(
|
||||||
Array<ExportableCredential & { filename: string }>
|
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', {
|
const remoteCredentialFiles = await glob('*.json', {
|
||||||
cwd: this.credentialExportFolder,
|
cwd: this.credentialExportFolder,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
});
|
});
|
||||||
const remoteCredentialFilesParsed = await Promise.all(
|
|
||||||
|
const accessibleProjects =
|
||||||
|
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
||||||
|
|
||||||
|
const remoteCredentialFilesRead = await Promise.all(
|
||||||
remoteCredentialFiles.map(async (file) => {
|
remoteCredentialFiles.map(async (file) => {
|
||||||
this.logger.debug(`Parsing credential file ${file}`);
|
this.logger.debug(`Parsing credential file ${file}`);
|
||||||
const remote = jsonParse<ExportableCredential>(
|
const remote = jsonParse<ExportableCredential>(
|
||||||
await fsReadFile(file, { encoding: 'utf8' }),
|
await fsReadFile(file, { encoding: 'utf8' }),
|
||||||
);
|
);
|
||||||
|
return remote;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteCredentialFilesParsed = remoteCredentialFilesRead
|
||||||
|
.filter((remote) => {
|
||||||
if (!remote?.id) {
|
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 {
|
return {
|
||||||
...remote,
|
...remote,
|
||||||
filename: getCredentialExportPath(remote.id, this.credentialExportFolder),
|
filename: getCredentialExportPath(remote.id, this.credentialExportFolder),
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array<
|
return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array<
|
||||||
ExportableCredential & { filename: string }
|
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({
|
const localCredentials = await this.credentialsRepository.find({
|
||||||
select: ['id', 'name', 'type'],
|
select: ['id', 'name', 'type'],
|
||||||
|
where:
|
||||||
|
this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context),
|
||||||
});
|
});
|
||||||
return localCredentials.map((local) => ({
|
return localCredentials.map((local) => ({
|
||||||
id: local.id,
|
id: local.id,
|
||||||
@@ -204,7 +297,7 @@ export class SourceControlImportService {
|
|||||||
return await this.variablesService.getAllCached();
|
return await this.variablesService.getAllCached();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteFoldersAndMappingsFromFile(): Promise<{
|
async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{
|
||||||
folders: ExportableFolder[];
|
folders: ExportableFolder[];
|
||||||
}> {
|
}> {
|
||||||
const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, {
|
const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, {
|
||||||
@@ -218,12 +311,22 @@ export class SourceControlImportService {
|
|||||||
}>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), {
|
}>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), {
|
||||||
fallbackValue: { folders: [] },
|
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 mappedFolders;
|
||||||
}
|
}
|
||||||
return { folders: [] };
|
return { folders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLocalFoldersAndMappingsFromDb(): Promise<{
|
async getLocalFoldersAndMappingsFromDb(context: SourceControlContext): Promise<{
|
||||||
folders: ExportableFolder[];
|
folders: ExportableFolder[];
|
||||||
}> {
|
}> {
|
||||||
const localFolders = await this.folderRepository.find({
|
const localFolders = await this.folderRepository.find({
|
||||||
@@ -236,6 +339,7 @@ export class SourceControlImportService {
|
|||||||
parentFolder: { id: true },
|
parentFolder: { id: true },
|
||||||
homeProject: { id: true },
|
homeProject: { id: true },
|
||||||
},
|
},
|
||||||
|
where: this.sourceControlScopedService.getFoldersInAdminProjectsFromContextFilter(context),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -250,26 +354,33 @@ export class SourceControlImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteTagsAndMappingsFromFile(): Promise<{
|
async getRemoteTagsAndMappingsFromFile(context: SourceControlContext): Promise<ExportableTags> {
|
||||||
tags: TagEntity[];
|
|
||||||
mappings: WorkflowTagMapping[];
|
|
||||||
}> {
|
|
||||||
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
|
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
|
||||||
cwd: this.gitFolder,
|
cwd: this.gitFolder,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
});
|
});
|
||||||
if (tagsFile.length > 0) {
|
if (tagsFile.length > 0) {
|
||||||
this.logger.debug(`Importing tags from file ${tagsFile[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' }),
|
await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
|
||||||
{ fallbackValue: { tags: [], mappings: [] } },
|
{ 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 mappedTags;
|
||||||
}
|
}
|
||||||
return { tags: [], mappings: [] };
|
return { tags: [], mappings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLocalTagsAndMappingsFromDb(): Promise<{
|
async getLocalTagsAndMappingsFromDb(context: SourceControlContext): Promise<{
|
||||||
tags: TagEntity[];
|
tags: TagEntity[];
|
||||||
mappings: WorkflowTagMapping[];
|
mappings: WorkflowTagMapping[];
|
||||||
}> {
|
}> {
|
||||||
@@ -278,6 +389,10 @@ export class SourceControlImportService {
|
|||||||
});
|
});
|
||||||
const localMappings = await this.workflowTagMappingRepository.find({
|
const localMappings = await this.workflowTagMappingRepository.find({
|
||||||
select: ['workflowId', 'tagId'],
|
select: ['workflowId', 'tagId'],
|
||||||
|
where:
|
||||||
|
this.sourceControlScopedService.getWorkflowTagMappingInAdminProjectsFromContextFilter(
|
||||||
|
context,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return { tags: localTags, mappings: localMappings };
|
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,
|
PushWorkFolderRequestDto,
|
||||||
SourceControlledFile,
|
SourceControlledFile,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import type { Variables, TagEntity, User } from '@n8n/db';
|
import {
|
||||||
import { FolderRepository, TagRepository } from '@n8n/db';
|
type Variables,
|
||||||
|
type TagEntity,
|
||||||
|
FolderRepository,
|
||||||
|
TagRepository,
|
||||||
|
type User,
|
||||||
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { hasGlobalScope } from '@n8n/permissions';
|
||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
import { UnexpectedError, UserError } from 'n8n-workflow';
|
import { UnexpectedError, UserError } from 'n8n-workflow';
|
||||||
@@ -13,6 +19,7 @@ import path from 'path';
|
|||||||
import type { PushResult } from 'simple-git';
|
import type { PushResult } from 'simple-git';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +45,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se
|
|||||||
import type { ExportableCredential } from './types/exportable-credential';
|
import type { ExportableCredential } from './types/exportable-credential';
|
||||||
import type { ExportableFolder } from './types/exportable-folders';
|
import type { ExportableFolder } from './types/exportable-folders';
|
||||||
import type { ImportResult } from './types/import-result';
|
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 { SourceControlGetStatus } from './types/source-control-get-status';
|
||||||
import type { SourceControlPreferences } from './types/source-control-preferences';
|
import type { SourceControlPreferences } from './types/source-control-preferences';
|
||||||
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
||||||
@@ -481,6 +489,13 @@ export class SourceControlService {
|
|||||||
async getStatus(user: User, options: SourceControlGetStatus) {
|
async getStatus(user: User, options: SourceControlGetStatus) {
|
||||||
await this.sanityCheck();
|
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[] = [];
|
const sourceControlledFiles: SourceControlledFile[] = [];
|
||||||
|
|
||||||
// fetch and reset hard first
|
// fetch and reset hard first
|
||||||
@@ -492,10 +507,10 @@ export class SourceControlService {
|
|||||||
wfMissingInLocal,
|
wfMissingInLocal,
|
||||||
wfMissingInRemote,
|
wfMissingInRemote,
|
||||||
wfModifiedInEither,
|
wfModifiedInEither,
|
||||||
} = await this.getStatusWorkflows(options, sourceControlledFiles);
|
} = await this.getStatusWorkflows(options, context, sourceControlledFiles);
|
||||||
|
|
||||||
const { credMissingInLocal, credMissingInRemote, credModifiedInEither } =
|
const { credMissingInLocal, credMissingInRemote, credModifiedInEither } =
|
||||||
await this.getStatusCredentials(options, sourceControlledFiles);
|
await this.getStatusCredentials(options, context, sourceControlledFiles);
|
||||||
|
|
||||||
const { varMissingInLocal, varMissingInRemote, varModifiedInEither } =
|
const { varMissingInLocal, varMissingInRemote, varModifiedInEither } =
|
||||||
await this.getStatusVariables(options, sourceControlledFiles);
|
await this.getStatusVariables(options, sourceControlledFiles);
|
||||||
@@ -506,10 +521,10 @@ export class SourceControlService {
|
|||||||
tagsModifiedInEither,
|
tagsModifiedInEither,
|
||||||
mappingsMissingInLocal,
|
mappingsMissingInLocal,
|
||||||
mappingsMissingInRemote,
|
mappingsMissingInRemote,
|
||||||
} = await this.getStatusTagsMappings(options, sourceControlledFiles);
|
} = await this.getStatusTagsMappings(options, context, sourceControlledFiles);
|
||||||
|
|
||||||
const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } =
|
const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } =
|
||||||
await this.getStatusFoldersMapping(options, sourceControlledFiles);
|
await this.getStatusFoldersMapping(options, context, sourceControlledFiles);
|
||||||
|
|
||||||
// #region Tracking Information
|
// #region Tracking Information
|
||||||
if (options.direction === 'push') {
|
if (options.direction === 'push') {
|
||||||
@@ -555,13 +570,33 @@ export class SourceControlService {
|
|||||||
|
|
||||||
private async getStatusWorkflows(
|
private async getStatusWorkflows(
|
||||||
options: SourceControlGetStatus,
|
options: SourceControlGetStatus,
|
||||||
|
context: SourceControlContext,
|
||||||
sourceControlledFiles: SourceControlledFile[],
|
sourceControlledFiles: SourceControlledFile[],
|
||||||
) {
|
) {
|
||||||
const wfRemoteVersionIds = await this.sourceControlImportService.getRemoteVersionIdsFromFiles();
|
// TODO: We need to check the case where it exists in the DB (out of scope) but is in GIT
|
||||||
const wfLocalVersionIds = await this.sourceControlImportService.getLocalVersionIdsFromDb();
|
const wfRemoteVersionIds =
|
||||||
|
await this.sourceControlImportService.getRemoteVersionIdsFromFiles(context);
|
||||||
|
const wfLocalVersionIds =
|
||||||
|
await this.sourceControlImportService.getLocalVersionIdsFromDb(context);
|
||||||
|
|
||||||
const wfMissingInLocal = wfRemoteVersionIds.filter(
|
let outOfScopeWF: SourceControlWorkflowVersionId[] = [];
|
||||||
(remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1,
|
|
||||||
|
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(
|
const wfMissingInRemote = wfLocalVersionIds.filter(
|
||||||
@@ -654,10 +689,12 @@ export class SourceControlService {
|
|||||||
|
|
||||||
private async getStatusCredentials(
|
private async getStatusCredentials(
|
||||||
options: SourceControlGetStatus,
|
options: SourceControlGetStatus,
|
||||||
|
context: SourceControlContext,
|
||||||
sourceControlledFiles: SourceControlledFile[],
|
sourceControlledFiles: SourceControlledFile[],
|
||||||
) {
|
) {
|
||||||
const credRemoteIds = await this.sourceControlImportService.getRemoteCredentialsFromFiles();
|
const credRemoteIds =
|
||||||
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb();
|
await this.sourceControlImportService.getRemoteCredentialsFromFiles(context);
|
||||||
|
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(context);
|
||||||
|
|
||||||
const credMissingInLocal = credRemoteIds.filter(
|
const credMissingInLocal = credRemoteIds.filter(
|
||||||
(remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1,
|
(remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1,
|
||||||
@@ -807,6 +844,7 @@ export class SourceControlService {
|
|||||||
|
|
||||||
private async getStatusTagsMappings(
|
private async getStatusTagsMappings(
|
||||||
options: SourceControlGetStatus,
|
options: SourceControlGetStatus,
|
||||||
|
context: SourceControlContext,
|
||||||
sourceControlledFiles: SourceControlledFile[],
|
sourceControlledFiles: SourceControlledFile[],
|
||||||
) {
|
) {
|
||||||
const lastUpdatedTag = await this.tagRepository.find({
|
const lastUpdatedTag = await this.tagRepository.find({
|
||||||
@@ -816,8 +854,9 @@ export class SourceControlService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tagMappingsRemote =
|
const tagMappingsRemote =
|
||||||
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile();
|
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context);
|
||||||
const tagMappingsLocal = await this.sourceControlImportService.getLocalTagsAndMappingsFromDb();
|
const tagMappingsLocal =
|
||||||
|
await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(context);
|
||||||
|
|
||||||
const tagsMissingInLocal = tagMappingsRemote.tags.filter(
|
const tagsMissingInLocal = tagMappingsRemote.tags.filter(
|
||||||
(remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1,
|
(remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1,
|
||||||
@@ -901,6 +940,7 @@ export class SourceControlService {
|
|||||||
|
|
||||||
private async getStatusFoldersMapping(
|
private async getStatusFoldersMapping(
|
||||||
options: SourceControlGetStatus,
|
options: SourceControlGetStatus,
|
||||||
|
context: SourceControlContext,
|
||||||
sourceControlledFiles: SourceControlledFile[],
|
sourceControlledFiles: SourceControlledFile[],
|
||||||
) {
|
) {
|
||||||
const lastUpdatedFolder = await this.folderRepository.find({
|
const lastUpdatedFolder = await this.folderRepository.find({
|
||||||
@@ -910,9 +950,9 @@ export class SourceControlService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const foldersMappingsRemote =
|
const foldersMappingsRemote =
|
||||||
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile();
|
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(context);
|
||||||
const foldersMappingsLocal =
|
const foldersMappingsLocal =
|
||||||
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb();
|
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(context);
|
||||||
|
|
||||||
const foldersMissingInLocal = foldersMappingsRemote.folders.filter(
|
const foldersMissingInLocal = foldersMappingsRemote.folders.filter(
|
||||||
(remote) => foldersMappingsLocal.folders.findIndex((local) => local.id === remote.id) === -1,
|
(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,9 +48,15 @@ export interface IWorkflowResponse extends IWorkflowBase {
|
|||||||
|
|
||||||
export interface IWorkflowToImport
|
export interface IWorkflowToImport
|
||||||
extends Omit<IWorkflowBase, 'staticData' | 'pinData' | 'createdAt' | 'updatedAt'> {
|
extends Omit<IWorkflowBase, 'staticData' | 'pinData' | 'createdAt' | 'updatedAt'> {
|
||||||
owner: {
|
owner:
|
||||||
|
| {
|
||||||
type: 'personal';
|
type: 'personal';
|
||||||
personalEmail: string;
|
personalEmail: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'team';
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
};
|
};
|
||||||
parentFolderId: string | null;
|
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 authOwnerAgent: SuperAgentTest;
|
||||||
let owner: User;
|
let owner: User;
|
||||||
|
|
||||||
mockInstance(Telemetry);
|
mockInstance(Telemetry);
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({
|
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