import type { SourceControlledFile } from '@n8n/api-types'; import { Container } from '@n8n/di'; import { constants as fsConstants, accessSync } from 'fs'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import path from 'path'; import { SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_GIT_FOLDER, } from '@/environments.ee/source-control/constants'; import { generateSshKeyPair, getRepoType, getTrackingInformationFromPostPushResult, getTrackingInformationFromPrePushResult, getTrackingInformationFromPullResult, isWorkflowModified, sourceControlFoldersExistCheck, } from '@/environments.ee/source-control/source-control-helper.ee'; import type { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import type { License } from '@/license'; import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id'; function createWorkflowVersion( overrides: Partial = {}, ): SourceControlWorkflowVersionId { return { id: 'workflow123', versionId: 'version1', filename: 'workflows/workflow123.json', parentFolderId: 'folder1', updatedAt: '2023-07-10T10:10:59.000Z', name: 'Test Workflow', ...overrides, }; } const pushResult: SourceControlledFile[] = [ { file: 'credential_stubs/kkookWGIeey9K4Kt.json', id: 'kkookWGIeey9K4Kt', name: '(deleted)', type: 'credential', status: 'deleted', location: 'local', conflict: false, updatedAt: '', pushed: true, }, { file: 'variable_stubs.json', id: 'variables', name: 'variables', type: 'variables', status: 'modified', location: 'local', conflict: false, updatedAt: '', pushed: true, }, { file: 'workflows/BpFS26gViuGqrIVP.json', id: 'BpFS26gViuGqrIVP', name: 'My workflow 5', type: 'workflow', status: 'modified', location: 'remote', conflict: true, pushed: true, updatedAt: '2023-07-10T10:10:59.000Z', }, { file: 'workflows/BpFS26gViuGqrIVP.json', id: 'BpFS26gViuGqrIVP', name: 'My workflow 5', type: 'workflow', status: 'modified', location: 'local', conflict: true, updatedAt: '2023-07-10T10:10:59.000Z', }, { file: 'workflows/dAU6dNthm4TR3gXx.json', id: 'dAU6dNthm4TR3gXx', name: 'My workflow 7', type: 'workflow', status: 'created', location: 'local', conflict: false, pushed: true, updatedAt: '2023-07-10T10:02:45.186Z', }, { file: 'workflows/haQetoXq9GxHSkft.json', id: 'haQetoXq9GxHSkft', name: 'My workflow 6', type: 'workflow', status: 'created', location: 'local', conflict: false, updatedAt: '2023-07-10T10:02:39.276Z', }, ]; const pullResult: SourceControlledFile[] = [ { file: 'credential_stubs/kkookWGIeey9K4Kt.json', id: 'kkookWGIeey9K4Kt', name: '(deleted)', type: 'credential', status: 'deleted', location: 'local', conflict: false, updatedAt: '', }, { file: 'credential_stubs/abcdeWGIeey9K4aa.json', id: 'abcdeWGIeey9K4aa', name: 'modfied credential', type: 'credential', status: 'modified', location: 'local', conflict: false, updatedAt: '', }, { file: 'workflows/BpFS26gViuGqrIVP.json', id: 'BpFS26gViuGqrIVP', name: '(deleted)', type: 'workflow', status: 'deleted', location: 'local', conflict: false, updatedAt: '', }, { file: 'variable_stubs.json', id: 'variables', name: 'variables', type: 'variables', status: 'modified', location: 'local', conflict: false, updatedAt: '', }, { file: 'workflows/dAU6dNthm4TR3gXx.json', id: 'dAU6dNthm4TR3gXx', name: 'My workflow 7', type: 'workflow', status: 'created', location: 'local', conflict: false, updatedAt: '2023-07-10T10:02:45.186Z', }, { file: 'workflows/haQetoXq9GxHSkft.json', id: 'haQetoXq9GxHSkft', name: 'My workflow 6', type: 'workflow', status: 'modified', location: 'local', conflict: false, updatedAt: '2023-07-10T10:02:39.276Z', }, ]; const license = mock(); const sourceControlPreferencesService = mock(); beforeAll(async () => { jest.resetAllMocks(); license.isSourceControlLicensed.mockReturnValue(true); sourceControlPreferencesService.getPreferences.mockReturnValue({ branchName: 'main', connected: true, repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git', branchReadOnly: false, branchColor: '#5296D6', publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBSz2nMZAiUBWe6n89aWd5x9QMcIOaznVW3fpuCYC4L n8n deploy key', }); }); describe('Source Control', () => { it('should generate an SSH key pair', async () => { const keyPair = await generateSshKeyPair('ed25519'); expect(keyPair.privateKey).toBeTruthy(); expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); expect(keyPair.publicKey).toBeTruthy(); expect(keyPair.publicKey).toContain('ssh-ed25519'); }); it('should generate an RSA key pair', async () => { const keyPair = await generateSshKeyPair('rsa'); expect(keyPair.privateKey).toBeTruthy(); expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); expect(keyPair.publicKey).toBeTruthy(); expect(keyPair.publicKey).toContain('ssh-rsa'); }); it('should check for git and ssh folders and create them if required', async () => { const { n8nFolder } = Container.get(InstanceSettings); const sshFolder = path.join(n8nFolder, SOURCE_CONTROL_SSH_FOLDER); const gitFolder = path.join(n8nFolder, SOURCE_CONTROL_GIT_FOLDER); let hasThrown = false; try { accessSync(sshFolder, fsConstants.F_OK); } catch (error) { hasThrown = true; } expect(hasThrown).toBeTruthy(); hasThrown = false; try { accessSync(gitFolder, fsConstants.F_OK); } catch (error) { hasThrown = true; } expect(hasThrown).toBeTruthy(); // create missing folders expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(false); // find folders this time expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(true); expect(accessSync(sshFolder, fsConstants.F_OK)).toBeUndefined(); expect(accessSync(gitFolder, fsConstants.F_OK)).toBeUndefined(); }); it('should get repo type from url', async () => { expect(getRepoType('git@github.com:n8ntest/n8n_testrepo.git')).toBe('github'); expect(getRepoType('git@gitlab.com:n8ntest/n8n_testrepo.git')).toBe('gitlab'); expect(getRepoType('git@mygitea.io:n8ntest/n8n_testrepo.git')).toBe('other'); }); it('should get tracking information from pre-push results', () => { const userId = 'userId'; const trackingResult = getTrackingInformationFromPrePushResult(userId, pushResult); expect(trackingResult).toEqual({ userId, workflowsEligible: 3, workflowsEligibleWithConflicts: 1, credsEligible: 1, credsEligibleWithConflicts: 0, variablesEligible: 1, }); }); it('should get tracking information from post-push results', () => { const userId = 'userId'; const trackingResult = getTrackingInformationFromPostPushResult(userId, pushResult); expect(trackingResult).toEqual({ userId, workflowsPushed: 2, workflowsEligible: 3, credsPushed: 1, variablesPushed: 1, }); }); it('should get tracking information from pull results', () => { const userId = 'userId'; const trackingResult = getTrackingInformationFromPullResult(userId, pullResult); expect(trackingResult).toEqual({ userId, credConflicts: 1, workflowConflicts: 1, workflowUpdates: 3, }); }); }); describe('isWorkflowModified', () => { it('should detect modifications when version IDs differ', () => { const local = createWorkflowVersion(); const remote = createWorkflowVersion({ versionId: 'version2' }); expect(isWorkflowModified(local, remote)).toBe(true); }); it('should detect modifications when parent folder IDs differ', () => { const local = createWorkflowVersion(); const remote = createWorkflowVersion({ parentFolderId: 'folder2' }); expect(isWorkflowModified(local, remote)).toBe(true); }); it('should not detect modifications when version IDs and parent folder IDs are the same', () => { const local = createWorkflowVersion(); const remote = createWorkflowVersion(); expect(isWorkflowModified(local, remote)).toBe(false); }); it('should not consider it modified when remote parent folder ID is undefined', () => { const local = createWorkflowVersion(); const remote = createWorkflowVersion({ parentFolderId: undefined }); expect(isWorkflowModified(local, remote)).toBe(false); }); it('should detect modifications when parent folder IDs differ and remote parent folder ID is defined', () => { const local = createWorkflowVersion({ parentFolderId: null }); const remote = createWorkflowVersion(); expect(isWorkflowModified(local, remote)).toBe(true); }); it('should handle null parent folder IDs correctly', () => { const local = createWorkflowVersion({ parentFolderId: null }); const remote = createWorkflowVersion({ parentFolderId: null }); expect(isWorkflowModified(local, remote)).toBe(false); }); });