diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index d035a1a3c3823..935a164a69de7 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -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({ file: mockProjectFile1, id: mockProjectData1.id }), + mock({ 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({ file: mockTeamProjectFile, id: mockTeamProjectData.id }), + mock({ + file: mockNonTeamProjectFile, + id: mockNonTeamProjectData.id, + }), + mock({ + 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({ file: mockProjectFile1, id: mockProjectData1.id }), - mock({ 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`]); + + fsReadFile + .mockResolvedValueOnce(JSON.stringify(mockProjectData1)) + .mockResolvedValueOnce(JSON.stringify(mockProjectData2)); + + // ACT + const result = await service.getRemoteProjectsFromFiles(globalAdminContext); + + // 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({ 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`, + }); + }); + }); - // Act - const result = await service.importTeamProjectsFromWorkFolder(candidates); + describe('getLocalTeamProjectsFromDb', () => { + it('should return team projects with the correct filter', async () => { + // ARRANGE + const mockProjectData1: Project = mock({ + id: 'project1', + name: 'Team Project 1', + icon: null, + description: 'First team project', + type: 'team', + createdAt: new Date(), + updatedAt: new Date(), + }); + const mockProjectData2: Project = mock({ + id: 'project2', + name: 'Team Project 2', + icon: { type: 'icon', value: 'icon2' }, + description: 'Second team project', + type: 'team', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const mockFilter = { id: 'test' }; + sourceControlScopedService.getProjectsWithPushScopeByContextFilter.mockReturnValue( + mockFilter, + ); + projectRepository.find.mockResolvedValue([mockProjectData1, mockProjectData2]); + + // ACT + const result = await service.getLocalTeamProjectsFromDb(globalAdminContext); + + // ASSERT + + // making sure the correct filter is used + expect(projectRepository.find).toHaveBeenCalledWith({ + select: ['id', 'name', 'description', 'icon', 'type'], + where: { + type: 'team', + ...mockFilter, + }, + }); - // Assert - expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile1, { encoding: 'utf8' }); - expect(fsReadFile).toHaveBeenCalledWith(mockProjectFile2, { encoding: 'utf8' }); - expect(projectRepository.upsert).toHaveBeenCalledWith( - expect.objectContaining({ + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ id: mockProjectData1.id, name: mockProjectData1.name, - icon: mockProjectData1.icon, description: mockProjectData1.description, + icon: mockProjectData1.icon, + filename: `/mock/n8n/git/projects/${mockProjectData1.id}.json`, type: mockProjectData1.type, - }), - ['id'], - ); - expect(projectRepository.upsert).toHaveBeenCalledWith( - expect.objectContaining({ + owner: { + type: 'team', + teamId: mockProjectData1.id, + teamName: mockProjectData1.name, + }, + }); + expect(result[1]).toMatchObject({ id: mockProjectData2.id, name: mockProjectData2.name, - icon: mockProjectData2.icon, description: mockProjectData2.description, + icon: mockProjectData2.icon, + filename: `/mock/n8n/git/projects/${mockProjectData2.id}.json`, type: mockProjectData2.type, - }), - ['id'], - ); - - expect(result).toEqual([ - { - id: mockProjectData1.id, - name: mockProjectData1.name, - }, - { - id: mockProjectData2.id, - name: mockProjectData2.name, - }, - ]); - }); + owner: { + type: 'team', + teamId: mockProjectData2.id, + teamName: 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: { + it('should return all team projects', async () => { + // ARRANGE + const mockProjectData1: Project = mock({ + 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', - }, - }; - - const candidates = [ - mock({ file: mockTeamProjectFile, id: mockTeamProjectData.id }), - mock({ file: mockNonTeamProjectFile, id: mockNonTeamProjectData.id }), - mock({ - file: mockInconsistentOwnerFile, - id: mockInconsistentOwnerData.id, - }), - ]; + createdAt: new Date(), + updatedAt: new Date(), + }); - fsReadFile - .mockResolvedValueOnce(JSON.stringify(mockTeamProjectData)) - .mockResolvedValueOnce(JSON.stringify(mockNonTeamProjectData)) - .mockResolvedValueOnce(JSON.stringify(mockInconsistentOwnerData)); + projectRepository.find.mockResolvedValue([mockProjectData1]); - const result = await service.importTeamProjectsFromWorkFolder(candidates); + // ACT + const result = await service.getLocalTeamProjectsFromDb(); - expect(fsReadFile).toHaveBeenCalledWith(mockTeamProjectFile, { encoding: 'utf8' }); - expect(fsReadFile).toHaveBeenCalledWith(mockNonTeamProjectFile, { encoding: 'utf8' }); - expect(fsReadFile).toHaveBeenCalledWith(mockInconsistentOwnerFile, { encoding: 'utf8' }); + // ASSERT - 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'], - ); + // making sure the correct filter is used + expect(projectRepository.find).toHaveBeenCalledWith({ + select: ['id', 'name', 'description', 'icon', 'type'], + where: { type: 'team' }, + }); - expect(result).toEqual([ - { - id: mockTeamProjectData.id, - name: mockTeamProjectData.name, - }, - ]); + 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, + }, + }); + }); }); }); }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts index 309de5603f4dc..ba90229f558ab 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts @@ -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,20 +46,57 @@ describe('getStatus', () => { mock(), ); - it('ensure updatedAt field for last deleted tag', async () => { - // ARRANGE - const user = mock({ - role: GLOBAL_ADMIN_ROLE, - }); + 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({ + role: GLOBAL_ADMIN_ROLE, + }); // Define a tag that does only exist remotely. // Pushing this means it was deleted. @@ -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(), + ]); + // 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 = { + 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({ + role: GLOBAL_ADMIN_ROLE, + }), + limitedUser: mock({ + 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, + }), + ]), + ); + }); + }); + }); }); diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index f29bc6db85163..c6a4af190217b 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -7,6 +7,7 @@ import type { User, WorkflowTagMapping, WorkflowEntity, + FindOptionsWhere, } from '@n8n/db'; import { SharedCredentials, @@ -31,36 +32,41 @@ import { jsonParse, ensureError, UserError, UnexpectedError } from 'n8n-workflow import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { CredentialsService } from '@/credentials/credentials.service'; +import type { IWorkflowToImport } from '@/interfaces'; +import { isUniqueConstraintError } from '@/response-helper'; +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, getWorkflowExportPath } from './source-control-helper.ee'; +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 } from './types/exportable-project'; +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'; -import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { CredentialsService } from '@/credentials/credentials.service'; -import type { IWorkflowToImport } from '@/interfaces'; -import { isUniqueConstraintError } from '@/response-helper'; -import { TagService } from '@/services/tag.service'; -import { assertNever } from '@/utils'; -import { WorkflowService } from '@/workflows/workflow.service'; - 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 { + 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(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 { + let where: FindOptionsWhere = { 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); diff --git a/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts index 04c33389b2834..8c00e72d8b829 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts @@ -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; + } } diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-project.ts b/packages/cli/src/environments.ee/source-control/types/exportable-project.ts index 25842ae4afeab..43571970a1df1 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-project.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-project.ts @@ -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; +}; diff --git a/packages/cli/src/environments.ee/source-control/types/resource-owner.ts b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts index 7901060c26f9a..24d5a1ffe7284 100644 --- a/packages/cli/src/environments.ee/source-control/types/resource-owner.ts +++ b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts @@ -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';