mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat: Add status check for project json files in git folder (#20369)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
d4b7cf0811
commit
2f38db86b5
|
|
@ -17,6 +17,7 @@ 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 type { ExportableProject } from '../types/exportable-project';
|
||||
import { SourceControlContext } from '../types/source-control-context';
|
||||
|
||||
jest.mock('fast-glob');
|
||||
|
|
@ -373,157 +374,358 @@ describe('SourceControlImportService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('importTeamProjectsFromWorkFolder', () => {
|
||||
it('should import team projects from work folder', async () => {
|
||||
// Arrange
|
||||
const mockProjectFile1 = '/mock/team-project1.json';
|
||||
const mockProjectFile2 = '/mock/team-project2.json';
|
||||
const mockProjectData1 = {
|
||||
describe('projects', () => {
|
||||
describe('importTeamProjectsFromWorkFolder', () => {
|
||||
it('should import team projects from work folder', async () => {
|
||||
// Arrange
|
||||
const mockProjectFile1 = '/mock/team-project1.json';
|
||||
const mockProjectFile2 = '/mock/team-project2.json';
|
||||
const mockProjectData1 = {
|
||||
id: 'project1',
|
||||
name: 'Team Project 1',
|
||||
icon: 'icon1.png',
|
||||
description: 'First team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'project1',
|
||||
},
|
||||
};
|
||||
const mockProjectData2 = {
|
||||
id: 'project2',
|
||||
name: 'Team Project 2',
|
||||
icon: 'icon2.png',
|
||||
description: 'Second team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'project2',
|
||||
},
|
||||
};
|
||||
const candidates = [
|
||||
mock<SourceControlledFile>({ file: mockProjectFile1, id: mockProjectData1.id }),
|
||||
mock<SourceControlledFile>({ file: mockProjectFile2, id: mockProjectData2.id }),
|
||||
];
|
||||
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData1))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData2));
|
||||
|
||||
// Act
|
||||
const result = await service.importTeamProjectsFromWorkFolder(candidates);
|
||||
|
||||
// Assert
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile1, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile2, { encoding: 'utf8' });
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
icon: mockProjectData1.icon,
|
||||
description: mockProjectData1.description,
|
||||
type: mockProjectData1.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockProjectData2.id,
|
||||
name: mockProjectData2.name,
|
||||
icon: mockProjectData2.icon,
|
||||
description: mockProjectData2.description,
|
||||
type: mockProjectData2.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
},
|
||||
{
|
||||
id: mockProjectData2.id,
|
||||
name: mockProjectData2.name,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should import only valid team projects and skip invalid ones', async () => {
|
||||
const mockTeamProjectFile = '/mock/project-team-valid.json';
|
||||
const mockTeamProjectData = {
|
||||
id: 'project-team-valid',
|
||||
name: 'Valid Team Project',
|
||||
icon: 'icon-team-valid',
|
||||
description: 'A valid team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'project-team-valid',
|
||||
},
|
||||
};
|
||||
const mockNonTeamProjectFile = '/mock/project-non-team.json';
|
||||
const mockNonTeamProjectData = {
|
||||
id: 'project-non-team',
|
||||
name: 'Personal Project',
|
||||
icon: 'icon-non-team',
|
||||
description: 'A personal project',
|
||||
type: 'personal', // not 'team'
|
||||
owner: {
|
||||
type: 'personal',
|
||||
personalEmail: 'user@email.com',
|
||||
},
|
||||
};
|
||||
const mockInconsistentOwnerFile = '/mock/project-inconsistent-owner.json';
|
||||
const mockInconsistentOwnerData = {
|
||||
id: 'project-team-inconsistent',
|
||||
name: 'Team Project Inconsistent',
|
||||
icon: 'icon-team-inconsistent',
|
||||
description: 'A team project with inconsistent owner',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'personal', // should be 'team'
|
||||
personalEmail: 'user@email.com',
|
||||
},
|
||||
};
|
||||
|
||||
const candidates = [
|
||||
mock<SourceControlledFile>({ file: mockTeamProjectFile, id: mockTeamProjectData.id }),
|
||||
mock<SourceControlledFile>({
|
||||
file: mockNonTeamProjectFile,
|
||||
id: mockNonTeamProjectData.id,
|
||||
}),
|
||||
mock<SourceControlledFile>({
|
||||
file: mockInconsistentOwnerFile,
|
||||
id: mockInconsistentOwnerData.id,
|
||||
}),
|
||||
];
|
||||
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockTeamProjectData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockNonTeamProjectData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockInconsistentOwnerData));
|
||||
|
||||
const result = await service.importTeamProjectsFromWorkFolder(candidates);
|
||||
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockTeamProjectFile, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockNonTeamProjectFile, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockInconsistentOwnerFile, { encoding: 'utf8' });
|
||||
|
||||
expect(projectRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockTeamProjectData.id,
|
||||
name: mockTeamProjectData.name,
|
||||
icon: mockTeamProjectData.icon,
|
||||
description: mockTeamProjectData.description,
|
||||
type: mockTeamProjectData.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockTeamProjectData.id,
|
||||
name: mockTeamProjectData.name,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteProjectsFromFiles', () => {
|
||||
const mockProjectData1: ExportableProject = {
|
||||
id: 'project1',
|
||||
name: 'Team Project 1',
|
||||
icon: 'icon1.png',
|
||||
icon: { type: 'icon', value: 'icon1' },
|
||||
description: 'First team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'project1',
|
||||
teamName: 'Team Project 1',
|
||||
},
|
||||
};
|
||||
const mockProjectData2 = {
|
||||
const mockProjectData2: ExportableProject = {
|
||||
id: 'project2',
|
||||
name: 'Team Project 2',
|
||||
icon: 'icon2.png',
|
||||
icon: { type: 'icon', value: 'icon2' },
|
||||
description: 'Second team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'project2',
|
||||
teamName: 'Team Project 2',
|
||||
},
|
||||
};
|
||||
const candidates = [
|
||||
mock<SourceControlledFile>({ file: mockProjectFile1, id: mockProjectData1.id }),
|
||||
mock<SourceControlledFile>({ file: mockProjectFile2, id: mockProjectData2.id }),
|
||||
];
|
||||
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData1))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData2));
|
||||
it('should return all projects if the user has access to all projects', async () => {
|
||||
// ARRANGE
|
||||
globMock.mockResolvedValue([`${mockProjectData1.id}.json`, `${mockProjectData2.id}.json`]);
|
||||
|
||||
// Act
|
||||
const result = await service.importTeamProjectsFromWorkFolder(candidates);
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData1))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData2));
|
||||
|
||||
// Assert
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile1, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile2, { encoding: 'utf8' });
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
icon: mockProjectData1.icon,
|
||||
description: mockProjectData1.description,
|
||||
type: mockProjectData1.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockProjectData2.id,
|
||||
name: mockProjectData2.name,
|
||||
icon: mockProjectData2.icon,
|
||||
description: mockProjectData2.description,
|
||||
type: mockProjectData2.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
// ACT
|
||||
const result = await service.getRemoteProjectsFromFiles(globalAdminContext);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
},
|
||||
{
|
||||
id: mockProjectData2.id,
|
||||
name: mockProjectData2.name,
|
||||
},
|
||||
]);
|
||||
// ASSERT
|
||||
expect(fsReadFile).toHaveBeenCalledTimes(2);
|
||||
expect(fsReadFile).toHaveBeenCalledWith(`${mockProjectData1.id}.json`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(fsReadFile).toHaveBeenCalledWith(`${mockProjectData2.id}.json`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
// expect the result to be the correct projects with the correct filename
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
...mockProjectData1,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData1.id}.json`,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
...mockProjectData2,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData2.id}.json`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return only projects that the user has access to', async () => {
|
||||
// ARRANGE
|
||||
globMock.mockResolvedValue([`${mockProjectData1.id}.json`, `${mockProjectData2.id}.json`]);
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData1))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockProjectData2));
|
||||
|
||||
// Only allow access to project2
|
||||
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
|
||||
mock<Project>({ id: mockProjectData2.id, type: 'team' }),
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const result = await service.getRemoteProjectsFromFiles(globalMemberContext);
|
||||
|
||||
// ASSERT
|
||||
expect(fsReadFile).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
...mockProjectData2,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData2.id}.json`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should import only valid team projects and skip invalid ones', async () => {
|
||||
const mockTeamProjectFile = '/mock/project-team-valid.json';
|
||||
const mockTeamProjectData = {
|
||||
id: 'project-team-valid',
|
||||
name: 'Valid Team Project',
|
||||
icon: 'icon-team-valid',
|
||||
description: 'A valid team project',
|
||||
type: 'team',
|
||||
owner: {
|
||||
describe('getLocalTeamProjectsFromDb', () => {
|
||||
it('should return team projects with the correct filter', async () => {
|
||||
// ARRANGE
|
||||
const mockProjectData1: Project = mock<Project>({
|
||||
id: 'project1',
|
||||
name: 'Team Project 1',
|
||||
icon: null,
|
||||
description: 'First team project',
|
||||
type: 'team',
|
||||
teamId: 'project-team-valid',
|
||||
},
|
||||
};
|
||||
const mockNonTeamProjectFile = '/mock/project-non-team.json';
|
||||
const mockNonTeamProjectData = {
|
||||
id: 'project-non-team',
|
||||
name: 'Personal Project',
|
||||
icon: 'icon-non-team',
|
||||
description: 'A personal project',
|
||||
type: 'personal', // not 'team'
|
||||
owner: {
|
||||
type: 'personal',
|
||||
personalEmail: 'user@email.com',
|
||||
},
|
||||
};
|
||||
const mockInconsistentOwnerFile = '/mock/project-inconsistent-owner.json';
|
||||
const mockInconsistentOwnerData = {
|
||||
id: 'project-team-inconsistent',
|
||||
name: 'Team Project Inconsistent',
|
||||
icon: 'icon-team-inconsistent',
|
||||
description: 'A team project with inconsistent owner',
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'personal', // should be 'team'
|
||||
personalEmail: 'user@email.com',
|
||||
},
|
||||
};
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const mockProjectData2: Project = mock<Project>({
|
||||
id: 'project2',
|
||||
name: 'Team Project 2',
|
||||
icon: { type: 'icon', value: 'icon2' },
|
||||
description: 'Second team project',
|
||||
type: 'team',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const candidates = [
|
||||
mock<SourceControlledFile>({ file: mockTeamProjectFile, id: mockTeamProjectData.id }),
|
||||
mock<SourceControlledFile>({ file: mockNonTeamProjectFile, id: mockNonTeamProjectData.id }),
|
||||
mock<SourceControlledFile>({
|
||||
file: mockInconsistentOwnerFile,
|
||||
id: mockInconsistentOwnerData.id,
|
||||
}),
|
||||
];
|
||||
const mockFilter = { id: 'test' };
|
||||
sourceControlScopedService.getProjectsWithPushScopeByContextFilter.mockReturnValue(
|
||||
mockFilter,
|
||||
);
|
||||
projectRepository.find.mockResolvedValue([mockProjectData1, mockProjectData2]);
|
||||
|
||||
fsReadFile
|
||||
.mockResolvedValueOnce(JSON.stringify(mockTeamProjectData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockNonTeamProjectData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockInconsistentOwnerData));
|
||||
// ACT
|
||||
const result = await service.getLocalTeamProjectsFromDb(globalAdminContext);
|
||||
|
||||
const result = await service.importTeamProjectsFromWorkFolder(candidates);
|
||||
// ASSERT
|
||||
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockTeamProjectFile, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockNonTeamProjectFile, { encoding: 'utf8' });
|
||||
expect(fsReadFile).toHaveBeenCalledWith(mockInconsistentOwnerFile, { encoding: 'utf8' });
|
||||
// making sure the correct filter is used
|
||||
expect(projectRepository.find).toHaveBeenCalledWith({
|
||||
select: ['id', 'name', 'description', 'icon', 'type'],
|
||||
where: {
|
||||
type: 'team',
|
||||
...mockFilter,
|
||||
},
|
||||
});
|
||||
|
||||
expect(projectRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(projectRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockTeamProjectData.id,
|
||||
name: mockTeamProjectData.name,
|
||||
icon: mockTeamProjectData.icon,
|
||||
description: mockTeamProjectData.description,
|
||||
type: mockTeamProjectData.type,
|
||||
}),
|
||||
['id'],
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
description: mockProjectData1.description,
|
||||
icon: mockProjectData1.icon,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData1.id}.json`,
|
||||
type: mockProjectData1.type,
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: mockProjectData1.id,
|
||||
teamName: mockProjectData1.name,
|
||||
},
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: mockProjectData2.id,
|
||||
name: mockProjectData2.name,
|
||||
description: mockProjectData2.description,
|
||||
icon: mockProjectData2.icon,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData2.id}.json`,
|
||||
type: mockProjectData2.type,
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: mockProjectData2.id,
|
||||
teamName: mockProjectData2.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockTeamProjectData.id,
|
||||
name: mockTeamProjectData.name,
|
||||
},
|
||||
]);
|
||||
it('should return all team projects', async () => {
|
||||
// ARRANGE
|
||||
const mockProjectData1: Project = mock<Project>({
|
||||
id: 'project1',
|
||||
name: 'Team Project 1',
|
||||
icon: null,
|
||||
description: 'First team project',
|
||||
type: 'team',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
projectRepository.find.mockResolvedValue([mockProjectData1]);
|
||||
|
||||
// ACT
|
||||
const result = await service.getLocalTeamProjectsFromDb();
|
||||
|
||||
// ASSERT
|
||||
|
||||
// making sure the correct filter is used
|
||||
expect(projectRepository.find).toHaveBeenCalledWith({
|
||||
select: ['id', 'name', 'description', 'icon', 'type'],
|
||||
where: { type: 'team' },
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: mockProjectData1.id,
|
||||
name: mockProjectData1.name,
|
||||
description: mockProjectData1.description,
|
||||
icon: mockProjectData1.icon,
|
||||
filename: `/mock/n8n/git/projects/${mockProjectData1.id}.json`,
|
||||
type: mockProjectData1.type,
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: mockProjectData1.id,
|
||||
teamName: mockProjectData1.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type { SourceControlImportService } from '../source-control-import.servic
|
|||
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
|
||||
import { SourceControlStatusService } from '../source-control-status.service.ee';
|
||||
import type { StatusExportableCredential } from '../types/exportable-credential';
|
||||
import type { ExportableProjectWithFileName } from '../types/exportable-project';
|
||||
import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id';
|
||||
|
||||
describe('getStatus', () => {
|
||||
|
|
@ -45,21 +46,58 @@ describe('getStatus', () => {
|
|||
mock<EventService>(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// version ids (workflows)
|
||||
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getAllLocalVersionIdsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
|
||||
|
||||
// credentials
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([]);
|
||||
|
||||
// variables
|
||||
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
|
||||
|
||||
// folders
|
||||
// Define a folder that does only exist remotely.
|
||||
// Pushing this means it was deleted.
|
||||
sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
|
||||
folders: [],
|
||||
});
|
||||
sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({
|
||||
folders: [],
|
||||
});
|
||||
|
||||
// tags
|
||||
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
|
||||
tags: [],
|
||||
mappings: [],
|
||||
});
|
||||
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
|
||||
tags: [],
|
||||
mappings: [],
|
||||
});
|
||||
|
||||
// projects
|
||||
sourceControlImportService.getRemoteProjectsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalTeamProjectsFromDb.mockResolvedValue([]);
|
||||
|
||||
// repositories
|
||||
tagRepository.find.mockResolvedValue([]);
|
||||
folderRepository.find.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('ensure updatedAt field for last deleted tag', async () => {
|
||||
// ARRANGE
|
||||
const user = mock<User>({
|
||||
role: GLOBAL_ADMIN_ROLE,
|
||||
});
|
||||
|
||||
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
|
||||
|
||||
tagRepository.find.mockResolvedValue([]);
|
||||
|
||||
// Define a tag that does only exist remotely.
|
||||
// Pushing this means it was deleted.
|
||||
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
|
||||
|
|
@ -76,14 +114,6 @@ describe('getStatus', () => {
|
|||
mappings: [],
|
||||
});
|
||||
|
||||
folderRepository.find.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
|
||||
folders: [],
|
||||
});
|
||||
sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({
|
||||
folders: [],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const pushResult = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
|
|
@ -107,26 +137,8 @@ describe('getStatus', () => {
|
|||
role: GLOBAL_ADMIN_ROLE,
|
||||
});
|
||||
|
||||
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
|
||||
|
||||
tagRepository.find.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
|
||||
tags: [],
|
||||
mappings: [],
|
||||
});
|
||||
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
|
||||
tags: [],
|
||||
mappings: [],
|
||||
});
|
||||
|
||||
// Define a folder that does only exist remotely.
|
||||
// Pushing this means it was deleted.
|
||||
folderRepository.find.mockResolvedValue([]);
|
||||
sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
|
||||
folders: [
|
||||
{
|
||||
|
|
@ -226,6 +238,15 @@ describe('getStatus', () => {
|
|||
],
|
||||
});
|
||||
|
||||
// Define a project that does only exist locally.
|
||||
// Pulling this would delete it so it should be marked as a conflict.
|
||||
// Pushing this is conflict free.
|
||||
|
||||
sourceControlImportService.getRemoteProjectsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalTeamProjectsFromDb.mockResolvedValue([
|
||||
mock<ExportableProjectWithFileName>(),
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const pullResult = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'pull',
|
||||
|
|
@ -248,8 +269,8 @@ describe('getStatus', () => {
|
|||
fail('Expected pushResult to be an array.');
|
||||
}
|
||||
|
||||
expect(pullResult).toHaveLength(5);
|
||||
expect(pushResult).toHaveLength(5);
|
||||
expect(pullResult).toHaveLength(6);
|
||||
expect(pushResult).toHaveLength(6);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false);
|
||||
|
|
@ -265,6 +286,9 @@ describe('getStatus', () => {
|
|||
|
||||
expect(pullResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', false);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'project')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'project')).toHaveProperty('conflict', false);
|
||||
});
|
||||
|
||||
it('should throw `ForbiddenError` if direction is pull and user is not allowed to globally pull', async () => {
|
||||
|
|
@ -282,4 +306,403 @@ describe('getStatus', () => {
|
|||
}),
|
||||
).rejects.toThrowError(ForbiddenError);
|
||||
});
|
||||
|
||||
describe('project status', () => {
|
||||
// Mock data for reusable test scenarios
|
||||
const mockProjects: Record<string, ExportableProjectWithFileName> = {
|
||||
basic: {
|
||||
id: 'project1',
|
||||
name: 'Test Project 1',
|
||||
description: 'Test Description 1',
|
||||
icon: { type: 'emoji', value: '🚀' },
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'team1',
|
||||
teamName: 'Team 1',
|
||||
},
|
||||
filename: '/mock/n8n/git/projects/project1.json',
|
||||
},
|
||||
withoutIcon: {
|
||||
id: 'project2',
|
||||
name: 'Test Project 2',
|
||||
description: 'Test Description 2',
|
||||
icon: null,
|
||||
type: 'team',
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: 'team2',
|
||||
teamName: 'Team 2',
|
||||
},
|
||||
filename: '/mock/n8n/git/projects/project2.json',
|
||||
},
|
||||
};
|
||||
|
||||
const mockUsers = {
|
||||
globalAdmin: mock<User>({
|
||||
role: GLOBAL_ADMIN_ROLE,
|
||||
}),
|
||||
limitedUser: mock<User>({
|
||||
role: GLOBAL_MEMBER_ROLE,
|
||||
}),
|
||||
};
|
||||
|
||||
const setupProjectMocks = ({
|
||||
remote,
|
||||
local,
|
||||
hiddenLocal = [],
|
||||
}: {
|
||||
remote: ExportableProjectWithFileName[];
|
||||
local: ExportableProjectWithFileName[];
|
||||
hiddenLocal?: ExportableProjectWithFileName[];
|
||||
}) => {
|
||||
sourceControlImportService.getRemoteProjectsFromFiles.mockResolvedValue(remote);
|
||||
sourceControlImportService.getLocalTeamProjectsFromDb.mockImplementation(async (context) => {
|
||||
if (context) {
|
||||
return local;
|
||||
}
|
||||
return [...local, ...hiddenLocal];
|
||||
});
|
||||
};
|
||||
|
||||
it('should return empty arrays when no projects exist locally or remotely', async () => {
|
||||
// ARRANGE
|
||||
const user = mockUsers.globalAdmin;
|
||||
setupProjectMocks({
|
||||
remote: [],
|
||||
local: [],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(result).toMatchObject({
|
||||
projectsRemote: [],
|
||||
projectsLocal: [],
|
||||
projectsMissingInLocal: [],
|
||||
projectsMissingInRemote: [],
|
||||
projectsModifiedInEither: [],
|
||||
sourceControlledFiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify projects missing in local (remote only)', async () => {
|
||||
// ARRANGE
|
||||
const user = mockUsers.globalAdmin;
|
||||
const remoteProject = mockProjects.basic;
|
||||
|
||||
// only remote project exists
|
||||
setupProjectMocks({
|
||||
remote: [remoteProject],
|
||||
local: [],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'pull',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (Array.isArray(result)) {
|
||||
fail('Expected result to be an object.');
|
||||
}
|
||||
|
||||
expect(result).toMatchObject({
|
||||
projectsRemote: [remoteProject],
|
||||
projectsLocal: [],
|
||||
projectsMissingInLocal: [remoteProject],
|
||||
projectsMissingInRemote: [],
|
||||
projectsModifiedInEither: [],
|
||||
sourceControlledFiles: [
|
||||
expect.objectContaining({
|
||||
id: remoteProject.id,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify projects missing in remote (local only)', async () => {
|
||||
// ARRANGE
|
||||
const user = mockUsers.globalAdmin;
|
||||
const localProject = mockProjects.basic;
|
||||
|
||||
// only local project exists
|
||||
setupProjectMocks({
|
||||
remote: [],
|
||||
local: [localProject],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (Array.isArray(result)) {
|
||||
fail('Expected result to be an object.');
|
||||
}
|
||||
|
||||
expect(result).toMatchObject({
|
||||
projectsRemote: [],
|
||||
projectsLocal: [localProject],
|
||||
projectsMissingInRemote: [localProject],
|
||||
projectsMissingInLocal: [],
|
||||
projectsModifiedInEither: [],
|
||||
sourceControlledFiles: [
|
||||
expect.objectContaining({
|
||||
id: localProject.id,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify projects modified in either location', async () => {
|
||||
// ARRANGE
|
||||
const user = mockUsers.globalAdmin;
|
||||
const localProject = mockProjects.basic;
|
||||
const remoteProject: ExportableProjectWithFileName = {
|
||||
...mockProjects.basic,
|
||||
icon: { type: 'icon', value: 'icon-modified' },
|
||||
};
|
||||
|
||||
// both projects exist but are different
|
||||
setupProjectMocks({
|
||||
remote: [remoteProject],
|
||||
local: [localProject],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (Array.isArray(result)) {
|
||||
fail('Expected result to be an object.');
|
||||
}
|
||||
|
||||
expect(result).toMatchObject({
|
||||
projectsRemote: [remoteProject],
|
||||
projectsLocal: [localProject],
|
||||
projectsMissingInLocal: [],
|
||||
projectsMissingInRemote: [],
|
||||
projectsModifiedInEither: [remoteProject],
|
||||
sourceControlledFiles: [
|
||||
expect.objectContaining({
|
||||
id: remoteProject.id,
|
||||
conflict: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent out of scope projects from being deleted for non-global users', async () => {
|
||||
// ARRANGE
|
||||
const user = mockUsers.limitedUser;
|
||||
const visibleProjects = [
|
||||
{
|
||||
...mockProjects.basic,
|
||||
id: 'project-1',
|
||||
},
|
||||
{
|
||||
...mockProjects.withoutIcon,
|
||||
id: 'project-2',
|
||||
},
|
||||
];
|
||||
|
||||
const hiddenProjects = [
|
||||
{
|
||||
...mockProjects.basic,
|
||||
id: 'project-3',
|
||||
},
|
||||
{
|
||||
...mockProjects.basic,
|
||||
id: 'project-4',
|
||||
},
|
||||
];
|
||||
|
||||
setupProjectMocks({
|
||||
remote: [...visibleProjects, ...hiddenProjects].map((project, index) => ({
|
||||
...project,
|
||||
name: `${project.name} changed ${index}`,
|
||||
})),
|
||||
local: visibleProjects,
|
||||
hiddenLocal: hiddenProjects,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: false,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (!Array.isArray(result)) {
|
||||
fail('Expected result to be an array.');
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(visibleProjects.length);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining(
|
||||
visibleProjects.map((project) =>
|
||||
expect.objectContaining({ id: project.id, status: 'modified' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('direction-based behavior', () => {
|
||||
const user = mockUsers.globalAdmin;
|
||||
const localProject1 = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-1',
|
||||
};
|
||||
const localProject2 = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-2',
|
||||
name: 'Project 2',
|
||||
};
|
||||
const localOnlyProject = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-3',
|
||||
name: 'Project 3',
|
||||
};
|
||||
const remoteProject1 = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-1',
|
||||
name: 'Remote 1',
|
||||
};
|
||||
const remoteProject2 = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-2',
|
||||
name: 'Project 2',
|
||||
description: 'Different description',
|
||||
};
|
||||
const remoteOnlyProject = {
|
||||
...mockProjects.basic,
|
||||
id: 'project-4',
|
||||
name: 'Project 4',
|
||||
};
|
||||
|
||||
it('should set correct status and conflict flags for push direction', async () => {
|
||||
// ARRANGE
|
||||
setupProjectMocks({
|
||||
remote: [remoteProject1, remoteProject2, remoteOnlyProject],
|
||||
local: [localProject1, localProject2, localOnlyProject],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: false,
|
||||
preferLocalVersion: true,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (!Array.isArray(result)) {
|
||||
fail('Expected result to be an array.');
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: localProject1.id,
|
||||
name: `${localProject1.name} (Remote: ${remoteProject1.name})`,
|
||||
conflict: true,
|
||||
location: 'local',
|
||||
status: 'modified',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: localProject2.id,
|
||||
name: localProject2.name,
|
||||
conflict: true,
|
||||
location: 'local',
|
||||
status: 'modified',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: localOnlyProject.id,
|
||||
name: localOnlyProject.name,
|
||||
conflict: false,
|
||||
location: 'local',
|
||||
status: 'created',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: remoteOnlyProject.id,
|
||||
name: remoteOnlyProject.name,
|
||||
conflict: false,
|
||||
location: 'local',
|
||||
status: 'deleted',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set correct status and conflict flags for pull direction', async () => {
|
||||
// ARRANGE
|
||||
setupProjectMocks({
|
||||
remote: [remoteProject1, remoteProject2, remoteOnlyProject],
|
||||
local: [localProject1, localProject2, localOnlyProject],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'pull',
|
||||
verbose: false,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
if (!Array.isArray(result)) {
|
||||
fail('Expected result to be an array.');
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: remoteProject1.id,
|
||||
name: `${remoteProject1.name} (Local: ${localProject1.name})`,
|
||||
status: 'modified',
|
||||
location: 'remote',
|
||||
conflict: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: remoteProject2.id,
|
||||
name: remoteProject2.name,
|
||||
status: 'modified',
|
||||
location: 'remote',
|
||||
conflict: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: localOnlyProject.id,
|
||||
name: localOnlyProject.name,
|
||||
status: 'deleted',
|
||||
location: 'remote',
|
||||
conflict: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: remoteOnlyProject.id,
|
||||
name: remoteOnlyProject.name,
|
||||
status: 'created',
|
||||
location: 'remote',
|
||||
conflict: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
User,
|
||||
WorkflowTagMapping,
|
||||
WorkflowEntity,
|
||||
FindOptionsWhere,
|
||||
} from '@n8n/db';
|
||||
import {
|
||||
SharedCredentials,
|
||||
|
|
@ -31,28 +32,6 @@ import { jsonParse, ensureError, UserError, UnexpectedError } from 'n8n-workflow
|
|||
import { readFile as fsReadFile } from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
|
||||
SOURCE_CONTROL_GIT_FOLDER,
|
||||
SOURCE_CONTROL_TAGS_EXPORT_FILE,
|
||||
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
|
||||
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,
|
||||
StatusExportableCredential,
|
||||
} from './types/exportable-credential';
|
||||
import type { ExportableFolder } from './types/exportable-folders';
|
||||
import type { ExportableProject } from './types/exportable-project';
|
||||
import type { ExportableTags } from './types/exportable-tags';
|
||||
import type { StatusResourceOwner, RemoteResourceOwner } 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';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { IWorkflowToImport } from '@/interfaces';
|
||||
|
|
@ -61,6 +40,33 @@ import { TagService } from '@/services/tag.service';
|
|||
import { assertNever } from '@/utils';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
import {
|
||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
|
||||
SOURCE_CONTROL_GIT_FOLDER,
|
||||
SOURCE_CONTROL_PROJECT_EXPORT_FOLDER,
|
||||
SOURCE_CONTROL_TAGS_EXPORT_FILE,
|
||||
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
|
||||
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
|
||||
} from './constants';
|
||||
import {
|
||||
getCredentialExportPath,
|
||||
getProjectExportPath,
|
||||
getWorkflowExportPath,
|
||||
} from './source-control-helper.ee';
|
||||
import { SourceControlScopedService } from './source-control-scoped.service';
|
||||
import type {
|
||||
ExportableCredential,
|
||||
StatusExportableCredential,
|
||||
} from './types/exportable-credential';
|
||||
import type { ExportableFolder } from './types/exportable-folders';
|
||||
import type { ExportableProject, ExportableProjectWithFileName } from './types/exportable-project';
|
||||
import type { ExportableTags } from './types/exportable-tags';
|
||||
import type { StatusResourceOwner, RemoteResourceOwner } 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';
|
||||
|
||||
const findOwnerProject = (
|
||||
owner: RemoteResourceOwner,
|
||||
accessibleProjects: Project[],
|
||||
|
|
@ -119,6 +125,8 @@ export class SourceControlImportService {
|
|||
|
||||
private credentialExportFolder: string;
|
||||
|
||||
private projectExportFolder: string;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
|
|
@ -146,6 +154,7 @@ export class SourceControlImportService {
|
|||
this.gitFolder,
|
||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
);
|
||||
this.projectExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_PROJECT_EXPORT_FOLDER);
|
||||
}
|
||||
|
||||
async getRemoteVersionIdsFromFiles(
|
||||
|
|
@ -523,6 +532,86 @@ export class SourceControlImportService {
|
|||
return { tags: localTags, mappings: localMappings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads projects from the git work folder and returns the projects that are accessible to the context user
|
||||
*/
|
||||
async getRemoteProjectsFromFiles(
|
||||
context: SourceControlContext,
|
||||
): Promise<ExportableProjectWithFileName[]> {
|
||||
const remoteProjectFiles = await glob('*.json', {
|
||||
cwd: this.projectExportFolder,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const remoteProjects = await Promise.all(
|
||||
remoteProjectFiles.map(async (file) => {
|
||||
this.logger.debug(`Parsing project file ${file}`);
|
||||
const fileContent = await fsReadFile(file, { encoding: 'utf8' });
|
||||
const parsedProject = jsonParse<ExportableProject>(fileContent);
|
||||
|
||||
return {
|
||||
...parsedProject,
|
||||
filename: getProjectExportPath(parsedProject.id, this.projectExportFolder),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (context.hasAccessToAllProjects()) {
|
||||
return remoteProjects;
|
||||
}
|
||||
|
||||
const accessibleProjects =
|
||||
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
|
||||
|
||||
return remoteProjects.filter((remoteProject) => {
|
||||
return findOwnerProject(remoteProject.owner, accessibleProjects);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches team projects from the database that are accessible to the context user
|
||||
* If context is not provided, it will return all team projects, regardless of the context user's access
|
||||
*/
|
||||
async getLocalTeamProjectsFromDb(
|
||||
context?: SourceControlContext,
|
||||
): Promise<ExportableProjectWithFileName[]> {
|
||||
let where: FindOptionsWhere<Project> = { type: 'team' };
|
||||
|
||||
if (context) {
|
||||
where = {
|
||||
type: 'team',
|
||||
...(this.sourceControlScopedService.getProjectsWithPushScopeByContextFilter(context) ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
const localProjects = await this.projectRepository.find({
|
||||
select: ['id', 'name', 'description', 'icon', 'type'],
|
||||
where,
|
||||
});
|
||||
|
||||
return localProjects.map((local) =>
|
||||
this.mapProjectEntityToExportableProjectWithFileName(local),
|
||||
);
|
||||
}
|
||||
|
||||
private mapProjectEntityToExportableProjectWithFileName(
|
||||
project: Project,
|
||||
): ExportableProjectWithFileName {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
icon: project.icon,
|
||||
filename: getProjectExportPath(project.id, this.projectExportFolder),
|
||||
type: 'team', // This is safe because we only select team projects
|
||||
owner: {
|
||||
type: 'team',
|
||||
teamId: project.id,
|
||||
teamName: project.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
|
||||
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
|
||||
const candidateIds = candidates.map((c) => c.id);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { Service } from '@n8n/di';
|
|||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import { SourceControlGitService } from './source-control-git.service.ee';
|
||||
import {
|
||||
getFoldersPath,
|
||||
|
|
@ -18,13 +21,11 @@ import { SourceControlImportService } from './source-control-import.service.ee';
|
|||
import { SourceControlPreferencesService } from './source-control-preferences.service.ee';
|
||||
import type { StatusExportableCredential } from './types/exportable-credential';
|
||||
import type { ExportableFolder } from './types/exportable-folders';
|
||||
import type { ExportableProjectWithFileName } from './types/exportable-project';
|
||||
import { SourceControlContext } from './types/source-control-context';
|
||||
import type { SourceControlGetStatus } from './types/source-control-get-status';
|
||||
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
@Service()
|
||||
export class SourceControlStatusService {
|
||||
constructor(
|
||||
|
|
@ -89,6 +90,14 @@ export class SourceControlStatusService {
|
|||
const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } =
|
||||
await this.getStatusFoldersMapping(options, context, sourceControlledFiles);
|
||||
|
||||
const {
|
||||
projectsRemote,
|
||||
projectsLocal,
|
||||
projectsMissingInLocal,
|
||||
projectsMissingInRemote,
|
||||
projectsModifiedInEither,
|
||||
} = await this.getStatusProjects(options, context, sourceControlledFiles);
|
||||
|
||||
// #region Tracking Information
|
||||
if (options.direction === 'push') {
|
||||
this.eventService.emit(
|
||||
|
|
@ -124,6 +133,11 @@ export class SourceControlStatusService {
|
|||
foldersMissingInLocal,
|
||||
foldersMissingInRemote,
|
||||
foldersModifiedInEither,
|
||||
projectsRemote,
|
||||
projectsLocal,
|
||||
projectsMissingInLocal,
|
||||
projectsMissingInRemote,
|
||||
projectsModifiedInEither,
|
||||
sourceControlledFiles,
|
||||
};
|
||||
} else {
|
||||
|
|
@ -198,6 +212,7 @@ export class SourceControlStatusService {
|
|||
let name =
|
||||
(options?.preferLocalVersion ? localWorkflow?.name : remoteWorkflowWithSameId?.name) ??
|
||||
'Workflow';
|
||||
|
||||
if (
|
||||
localWorkflow.name &&
|
||||
remoteWorkflowWithSameId?.name &&
|
||||
|
|
@ -207,6 +222,7 @@ export class SourceControlStatusService {
|
|||
? `${localWorkflow.name} (Remote: ${remoteWorkflowWithSameId.name})`
|
||||
: (name = `${remoteWorkflowWithSameId.name} (Local: ${localWorkflow.name})`);
|
||||
}
|
||||
|
||||
wfModifiedInEither.push({
|
||||
...localWorkflow,
|
||||
name,
|
||||
|
|
@ -606,4 +622,176 @@ export class SourceControlStatusService {
|
|||
foldersModifiedInEither,
|
||||
};
|
||||
}
|
||||
|
||||
private async getStatusProjects(
|
||||
options: SourceControlGetStatus,
|
||||
context: SourceControlContext,
|
||||
sourceControlledFiles: SourceControlledFile[],
|
||||
) {
|
||||
const projectsRemote =
|
||||
await this.sourceControlImportService.getRemoteProjectsFromFiles(context);
|
||||
const projectsLocal = await this.sourceControlImportService.getLocalTeamProjectsFromDb(context);
|
||||
|
||||
let outOfScopeProjects: ExportableProjectWithFileName[] = [];
|
||||
|
||||
if (!context.hasAccessToAllProjects()) {
|
||||
// we need to query for all projects in the DB to hide possible deletions,
|
||||
// when a project went out of scope locally
|
||||
outOfScopeProjects = await this.sourceControlImportService.getLocalTeamProjectsFromDb();
|
||||
outOfScopeProjects = outOfScopeProjects.filter(
|
||||
(project) => !projectsLocal.some((local) => local.id === project.id),
|
||||
);
|
||||
}
|
||||
|
||||
const projectsMissingInLocal = projectsRemote
|
||||
.filter((remote) => !projectsLocal.some((local) => local.id === remote.id))
|
||||
.filter(
|
||||
// If we have out of scope projects, these are projects that are not
|
||||
// visible locally, but exist locally and are available in remote
|
||||
// we skip them and hide them from deletion from the user.
|
||||
(remote) => !outOfScopeProjects.some((outOfScope) => outOfScope.id === remote.id),
|
||||
);
|
||||
|
||||
const projectsMissingInRemote = projectsLocal.filter(
|
||||
(local) => !projectsRemote.some((remote) => remote.id === local.id),
|
||||
);
|
||||
|
||||
const projectsModifiedInEither: ExportableProjectWithFileName[] = [];
|
||||
|
||||
projectsLocal.forEach((localProject) => {
|
||||
const remoteProjectWithSameId = projectsRemote.find(
|
||||
(remoteProject) => remoteProject.id === localProject.id,
|
||||
);
|
||||
|
||||
if (!remoteProjectWithSameId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isProjectModified(localProject, remoteProjectWithSameId)) {
|
||||
let name =
|
||||
(options?.preferLocalVersion ? localProject?.name : remoteProjectWithSameId?.name) ??
|
||||
'Project';
|
||||
|
||||
if (
|
||||
localProject.name &&
|
||||
remoteProjectWithSameId?.name &&
|
||||
localProject.name !== remoteProjectWithSameId.name
|
||||
) {
|
||||
name = options?.preferLocalVersion
|
||||
? `${localProject.name} (Remote: ${remoteProjectWithSameId.name})`
|
||||
: `${remoteProjectWithSameId.name} (Local: ${localProject.name})`;
|
||||
}
|
||||
|
||||
projectsModifiedInEither.push({
|
||||
...localProject,
|
||||
name,
|
||||
description: options.preferLocalVersion
|
||||
? localProject.description
|
||||
: remoteProjectWithSameId.description,
|
||||
icon: options.preferLocalVersion ? localProject.icon : remoteProjectWithSameId.icon,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const mapExportableProjectWithFileNameToSourceControlledFile = ({
|
||||
project,
|
||||
status,
|
||||
conflict,
|
||||
}: {
|
||||
project: ExportableProjectWithFileName;
|
||||
status: SourceControlledFile['status'];
|
||||
conflict: boolean;
|
||||
}): SourceControlledFile => {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name ?? 'Project',
|
||||
type: 'project',
|
||||
status,
|
||||
location: options.direction === 'push' ? 'local' : 'remote',
|
||||
conflict,
|
||||
file: project.filename,
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: {
|
||||
type: project.owner.type,
|
||||
projectId: project.owner.teamId,
|
||||
projectName: project.owner.teamName,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
projectsMissingInLocal.forEach((item) => {
|
||||
sourceControlledFiles.push(
|
||||
mapExportableProjectWithFileNameToSourceControlledFile({
|
||||
project: item,
|
||||
status: options.direction === 'push' ? 'deleted' : 'created',
|
||||
conflict: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
projectsMissingInRemote.forEach((item) => {
|
||||
sourceControlledFiles.push(
|
||||
mapExportableProjectWithFileNameToSourceControlledFile({
|
||||
project: item,
|
||||
status: options.direction === 'push' ? 'created' : 'deleted',
|
||||
conflict: options.direction === 'push' ? false : true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
projectsModifiedInEither.forEach((item) => {
|
||||
sourceControlledFiles.push(
|
||||
mapExportableProjectWithFileNameToSourceControlledFile({
|
||||
project: item,
|
||||
status: 'modified',
|
||||
conflict: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
projectsRemote,
|
||||
projectsLocal,
|
||||
projectsMissingInLocal,
|
||||
projectsMissingInRemote,
|
||||
projectsModifiedInEither,
|
||||
};
|
||||
}
|
||||
|
||||
private isProjectModified(
|
||||
local: ExportableProjectWithFileName,
|
||||
remote: ExportableProjectWithFileName,
|
||||
): boolean {
|
||||
const isIconModified = this.isProjectIconModified({
|
||||
localIcon: local.icon,
|
||||
remoteIcon: remote.icon,
|
||||
});
|
||||
|
||||
return (
|
||||
isIconModified ||
|
||||
remote.type !== local.type ||
|
||||
remote.name !== local.name ||
|
||||
remote.description !== local.description
|
||||
);
|
||||
}
|
||||
|
||||
private isProjectIconModified({
|
||||
localIcon,
|
||||
remoteIcon,
|
||||
}: {
|
||||
localIcon: ExportableProjectWithFileName['icon'];
|
||||
remoteIcon: ExportableProjectWithFileName['icon'];
|
||||
}): boolean {
|
||||
// If one has an icon and the other doesn't, it's modified
|
||||
if (!remoteIcon && !!localIcon) return true;
|
||||
if (!!remoteIcon && !localIcon) return true;
|
||||
|
||||
// If both have icons, compare their properties
|
||||
if (!!remoteIcon && !!localIcon) {
|
||||
return remoteIcon.type !== localIcon.type || remoteIcon.value !== localIcon.value;
|
||||
}
|
||||
|
||||
// Neither has an icon, so no modification
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RemoteResourceOwner } from './resource-owner';
|
||||
import type { TeamResourceOwner } from './resource-owner';
|
||||
|
||||
export interface ExportableProject {
|
||||
id: string;
|
||||
|
|
@ -9,5 +9,9 @@ export interface ExportableProject {
|
|||
* Only team projects are supported
|
||||
*/
|
||||
type: 'team';
|
||||
owner: RemoteResourceOwner;
|
||||
owner: TeamResourceOwner;
|
||||
}
|
||||
|
||||
export type ExportableProjectWithFileName = ExportableProject & {
|
||||
filename: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
export type RemoteResourceOwner =
|
||||
| string
|
||||
| {
|
||||
type: 'personal';
|
||||
projectId?: string; // Optional for retrocompatibility
|
||||
projectName?: string; // Optional for retrocompatibility
|
||||
personalEmail: string;
|
||||
}
|
||||
| {
|
||||
type: 'team';
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
};
|
||||
/**
|
||||
* When the owner is a personal, it represents the personal project that owns the resource.
|
||||
*/
|
||||
export type PersonalResourceOwner = {
|
||||
type: 'personal';
|
||||
/**
|
||||
* The personal project id
|
||||
*/
|
||||
projectId?: string; // Optional for retrocompatibility
|
||||
/**
|
||||
* The personal project name (usually the user name)
|
||||
*/
|
||||
projectName?: string; // Optional for retrocompatibility
|
||||
personalEmail: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* When the owner is a team, it represents the team project that owns the resource.
|
||||
*/
|
||||
export type TeamResourceOwner = {
|
||||
type: 'team';
|
||||
/**
|
||||
* The team project id
|
||||
*/
|
||||
teamId: string;
|
||||
/**
|
||||
* The team project name
|
||||
*/
|
||||
teamName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* When the owner is a string, it represents the personal email of the user who owns the resource.
|
||||
*/
|
||||
export type RemoteResourceOwner = string | PersonalResourceOwner | TeamResourceOwner;
|
||||
|
||||
export type StatusResourceOwner = {
|
||||
type: 'personal' | 'team';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user