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:
Irénée 2025-10-06 16:19:50 +01:00 committed by GitHub
parent d4b7cf0811
commit 2f38db86b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1125 additions and 197 deletions

View File

@ -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,
},
});
});
});
});
});

View File

@ -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,
}),
]),
);
});
});
});
});

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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';