From 5c6094dfd7ac012133cb6540929f2b962523f458 Mon Sep 17 00:00:00 2001 From: Idir Ouhab Meskine Date: Wed, 10 Sep 2025 16:08:07 +0200 Subject: [PATCH] feat(core): Add HTTPS protocol support for repository connections (#18250) Co-authored-by: konstantintieber --- .../source-control-git.service.test.ts | 142 ++++++- .../source-control-https.service.ee.test.ts | 175 +++++++++ .../source-control-integration.test.ts | 235 ++++++++++++ ...ntrol-preferences-https.service.ee.test.ts | 308 +++++++++++++++ ...urce-control-service-disconnect.ee.test.ts | 204 ++++++++++ .../source-control-git.service.ee.ts | 108 ++++-- .../source-control-preferences.service.ee.ts | 66 +++- .../source-control.service.ee.ts | 12 +- .../types/source-control-preferences.ts | 17 +- .../__tests__/insights.service.test.ts | 59 +-- .../frontend/@n8n/i18n/src/locales/en.json | 16 + .../src/stores/sourceControl.store.test.ts | 272 ++++++++++++++ .../src/stores/sourceControl.store.ts | 1 + .../src/types/sourceControl.types.ts | 1 + .../src/views/SettingsSourceControl.test.ts | 52 +++ .../src/views/SettingsSourceControl.vue | 352 +++++++++++++----- 16 files changed, 1858 insertions(+), 162 deletions(-) create mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts create mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts create mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts create mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts create mode 100644 packages/frontend/editor-ui/src/stores/sourceControl.store.test.ts diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts index 2bc0b8d7e8..3e14c18b6a 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts @@ -54,7 +54,7 @@ describe('SourceControlGitService', () => { const checkoutSpy = jest.spyOn(git, 'checkout'); const branchSpy = jest.spyOn(git, 'branch'); gitService.git = git; - jest.spyOn(gitService, 'setGitSshCommand').mockResolvedValue(); + jest.spyOn(gitService, 'setGitCommand').mockResolvedValue(); jest .spyOn(gitService, 'getBranches') .mockResolvedValue({ currentBranch: '', branches: ['main'] }); @@ -71,6 +71,107 @@ describe('SourceControlGitService', () => { expect(branchSpy).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']); }); }); + + describe('repository URL authorization', () => { + it('should use original URL for SSH connection type', async () => { + /** + * Arrange + */ + const mockPreferencesService = mock(); + const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); + const originUrl = 'git@github.com:user/repo.git'; + const prefs = mock({ + repositoryUrl: originUrl, + connectionType: 'ssh', + branchName: 'main', + }); + const user = mock(); + const git = mock(); + const addRemoteSpy = jest.spyOn(git, 'addRemote'); + jest.spyOn(gitService, 'setGitUserDetails').mockResolvedValue(); + // Mock getBranches and fetch to avoid remote tracking logic + jest + .spyOn(gitService, 'getBranches') + .mockResolvedValue({ currentBranch: 'main', branches: [] }); + jest.spyOn(gitService, 'fetch').mockResolvedValue({} as any); + gitService.git = git; + + /** + * Act + */ + await gitService.initRepository(prefs, user); + + /** + * Assert + */ + expect(addRemoteSpy).toHaveBeenCalledWith('origin', originUrl); + expect(mockPreferencesService.getDecryptedHttpsCredentials).not.toHaveBeenCalled(); + }); + + it('should add credentials to HTTPS URL when connection type is https', async () => { + /** + * Arrange + */ + const mockPreferencesService = mock(); + const credentials = { username: 'testuser', password: 'test:pass#word' }; + mockPreferencesService.getDecryptedHttpsCredentials.mockResolvedValue(credentials); + + const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); + const expectedUrl = 'https://testuser:test%3Apass%23word@github.com/user/repo.git'; + const prefs = mock({ + repositoryUrl: 'https://github.com/user/repo.git', + connectionType: 'https', + branchName: 'main', + }); + const user = mock(); + const git = mock(); + const addRemoteSpy = jest.spyOn(git, 'addRemote'); + jest.spyOn(gitService, 'setGitUserDetails').mockResolvedValue(); + // Mock getBranches and fetch to avoid remote tracking logic + jest + .spyOn(gitService, 'getBranches') + .mockResolvedValue({ currentBranch: 'main', branches: [] }); + jest.spyOn(gitService, 'fetch').mockResolvedValue({} as any); + gitService.git = git; + + /** + * Act + */ + await gitService.initRepository(prefs, user); + + /** + * Assert + */ + expect(mockPreferencesService.getDecryptedHttpsCredentials).toHaveBeenCalled(); + expect(addRemoteSpy).toHaveBeenCalledWith('origin', expectedUrl); + }); + + it('should throw error when HTTPS connection type is specified but no credentials found', async () => { + /** + * Arrange + */ + const mockPreferencesService = mock(); + mockPreferencesService.getDecryptedHttpsCredentials.mockResolvedValue(null); + + const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); + const prefs = mock({ + repositoryUrl: 'https://github.com/user/repo.git', + connectionType: 'https', + branchName: 'main', + }); + const user = mock(); + const git = mock(); + gitService.git = git; + + /** + * Act & Assert + */ + await expect(gitService.initRepository(prefs, user)).rejects.toThrow( + 'HTTPS connection type specified but no credentials found', + ); + expect(mockPreferencesService.getDecryptedHttpsCredentials).toHaveBeenCalled(); + }); + }); }); describe('getFileContent', () => { @@ -124,11 +225,22 @@ describe('SourceControlGitService', () => { // Mock the getPrivateKeyPath to return a Windows path mockPreferencesService.getPrivateKeyPath.mockResolvedValue(windowsPath); + // Mock getPreferences to return SSH connection type (required for new functionality) + mockPreferencesService.getPreferences.mockReturnValue({ + connectionType: 'ssh', + connected: true, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + initRepo: false, + keyGeneratorType: 'ed25519', + }); const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); // Act - await gitService.setGitSshCommand('/git/folder', sshFolder); + await gitService.setGitCommand('/git/folder', sshFolder); // Assert - verify Windows paths are normalized to POSIX format expect(mockGitInstance.env).toHaveBeenCalledWith( @@ -154,11 +266,22 @@ describe('SourceControlGitService', () => { // Mock the getPrivateKeyPath to return a path with spaces mockPreferencesService.getPrivateKeyPath.mockResolvedValue(privateKeyPath); + // Mock getPreferences to return SSH connection type + mockPreferencesService.getPreferences.mockReturnValue({ + connectionType: 'ssh', + connected: true, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + initRepo: false, + keyGeneratorType: 'ed25519', + }); const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); // Act - await gitService.setGitSshCommand('/git/folder', sshFolder); + await gitService.setGitCommand('/git/folder', sshFolder); // Assert - verify paths with spaces are properly quoted expect(mockGitInstance.env).toHaveBeenCalledWith( @@ -187,11 +310,22 @@ describe('SourceControlGitService', () => { // Mock the getPrivateKeyPath to return a path with quotes mockPreferencesService.getPrivateKeyPath.mockResolvedValue(pathWithQuotes); + // Mock getPreferences to return SSH connection type + mockPreferencesService.getPreferences.mockReturnValue({ + connectionType: 'ssh', + connected: true, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + initRepo: false, + keyGeneratorType: 'ed25519', + }); const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); // Act - await gitService.setGitSshCommand('/git/folder', sshFolder); + await gitService.setGitCommand('/git/folder', sshFolder); // Assert - verify the SSH command was properly escaped expect(mockGitInstance.env).toHaveBeenCalledWith( diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts new file mode 100644 index 0000000000..1c8739347f --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts @@ -0,0 +1,175 @@ +import { mock } from 'jest-mock-extended'; + +import { SourceControlGitService } from '../source-control-git.service.ee'; +import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; +import type { SourceControlPreferences } from '../types/source-control-preferences'; + +const mockSimpleGit = { + env: jest.fn(), + init: jest.fn().mockResolvedValue(undefined), + addRemote: jest.fn().mockResolvedValue(undefined), + getRemotes: jest.fn().mockResolvedValue([]), +}; + +mockSimpleGit.env.mockReturnValue(mockSimpleGit); + +jest.mock('simple-git', () => ({ + simpleGit: jest.fn().mockReturnValue(mockSimpleGit), +})); + +describe('SourceControlGitService - HTTPS functionality', () => { + let sourceControlGitService: SourceControlGitService; + let sourceControlPreferencesService: SourceControlPreferencesService; + + const mockPreferences: SourceControlPreferences = { + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + connectionType: 'https', + branchReadOnly: false, + branchColor: '#5296D6', + connected: false, + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + beforeEach(() => { + sourceControlPreferencesService = mock(); + + sourceControlGitService = new SourceControlGitService( + mock(), + mock(), + sourceControlPreferencesService, + ); + + jest.spyOn(sourceControlPreferencesService, 'getPreferences').mockReturnValue(mockPreferences); + jest.clearAllMocks(); + + mockSimpleGit.env.mockReturnValue(mockSimpleGit); + }); + + afterEach(() => { + mockSimpleGit.env.mockClear(); + mockSimpleGit.init.mockClear(); + mockSimpleGit.addRemote.mockClear(); + mockSimpleGit.getRemotes.mockClear(); + }); + + describe('setGitCommand', () => { + it('should configure git for HTTPS without SSH command', async () => { + await sourceControlGitService.setGitCommand(); + + expect(mockSimpleGit.env).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0'); + expect(mockSimpleGit.env).not.toHaveBeenCalledWith('GIT_SSH_COMMAND', expect.any(String)); + }); + + it('should configure git for SSH with SSH command when connectionType is ssh', async () => { + const sshPreferences = { ...mockPreferences, connectionType: 'ssh' as const }; + jest.spyOn(sourceControlPreferencesService, 'getPreferences').mockReturnValue(sshPreferences); + jest + .spyOn(sourceControlPreferencesService, 'getPrivateKeyPath') + .mockResolvedValue('/path/to/key'); + + await sourceControlGitService.setGitCommand('/git/folder', '/ssh/folder'); + + expect(sourceControlPreferencesService.getPrivateKeyPath).toHaveBeenCalled(); + expect(mockSimpleGit.env).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0'); + expect(mockSimpleGit.env).toHaveBeenCalledWith( + 'GIT_SSH_COMMAND', + expect.stringContaining('ssh'), + ); + }); + }); + + describe('URL normalization logic', () => { + it('should normalize HTTPS URLs for comparison', () => { + const remoteWithCredentials = 'https://user:token@github.com/user/repo.git'; + const inputWithoutCredentials = 'https://github.com/user/repo.git'; + + const normalizeUrl = (url: string) => { + try { + const urlObj = new URL(url); + urlObj.username = ''; + urlObj.password = ''; + return urlObj.toString(); + } catch { + return url; + } + }; + + const normalizedRemote = normalizeUrl(remoteWithCredentials); + const normalizedInput = normalizeUrl(inputWithoutCredentials); + + expect(normalizedRemote).toBe(normalizedInput); + }); + + it('should handle malformed URLs gracefully', () => { + const malformedUrl = 'not-a-valid-url'; + + const normalizeUrl = (url: string) => { + try { + const urlObj = new URL(url); + urlObj.username = ''; + urlObj.password = ''; + return urlObj.toString(); + } catch { + return url; + } + }; + + const result = normalizeUrl(malformedUrl); + + expect(result).toBe(malformedUrl); // Should return original when URL parsing fails + }); + }); + + describe('URL encoding in repository initialization', () => { + it('should properly encode credentials with special characters', () => { + const mockCredentials = { + username: 'user@domain.com', + password: 'p@ssw0rd!', + }; + + const baseUrl = 'https://github.com/user/repo.git'; + + const urlObj = new URL(baseUrl); + urlObj.username = encodeURIComponent(mockCredentials.username); + urlObj.password = encodeURIComponent(mockCredentials.password); + const encodedUrl = urlObj.toString(); + + expect(encodedUrl).toContain('user%40domain.com'); + expect(encodedUrl).toContain('p%40ssw0rd'); + expect(encodedUrl).toMatch( + /^https:\/\/user%40domain\.com:p%40ssw0rd[!%].*@github\.com\/user\/repo\.git$/, + ); + }); + + it('should handle credentials without special characters', () => { + // Arrange + const mockCredentials = { + username: 'testuser', + password: 'testtoken123', + }; + + const baseUrl = 'https://github.com/user/repo.git'; + + const urlObj = new URL(baseUrl); + urlObj.username = encodeURIComponent(mockCredentials.username); + urlObj.password = encodeURIComponent(mockCredentials.password); + const encodedUrl = urlObj.toString(); + + expect(encodedUrl).toBe('https://testuser:testtoken123@github.com/user/repo.git'); + }); + }); + + describe('Connection type handling', () => { + it('should differentiate between SSH and HTTPS configuration', () => { + const httpsPrefs = { connectionType: 'https' as const }; + const sshPrefs = { connectionType: 'ssh' as const }; + + expect(httpsPrefs.connectionType).toBe('https'); + expect(sshPrefs.connectionType).toBe('ssh'); + expect(httpsPrefs.connectionType).not.toBe(sshPrefs.connectionType); + }); + }); +}); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts new file mode 100644 index 0000000000..654ac18eb7 --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts @@ -0,0 +1,235 @@ +import { mock } from 'jest-mock-extended'; + +import type { SourceControlGitService } from '../source-control-git.service.ee'; +import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; +import type { SourceControlPreferences } from '../types/source-control-preferences'; + +describe('SourceControl Integration Tests', () => { + let mockGitService: SourceControlGitService; + let mockPreferencesService: SourceControlPreferencesService; + + beforeEach(() => { + mockGitService = mock(); + mockPreferencesService = mock(); + }); + + describe('HTTPS vs SSH Integration', () => { + it('should handle HTTPS connection flow', async () => { + // Arrange + const httpsPrefs: SourceControlPreferences = { + connected: true, + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + connectionType: 'https', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(httpsPrefs); + jest.spyOn(mockPreferencesService, 'getDecryptedHttpsCredentials').mockResolvedValue({ + username: 'testuser', + password: 'testtoken', + }); + + // Act & Assert + expect(httpsPrefs.connectionType).toBe('https'); + expect(mockPreferencesService.getPreferences().connectionType).toBe('https'); + }); + + it('should handle SSH connection flow', async () => { + // Arrange + const sshPrefs: SourceControlPreferences = { + connected: true, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + connectionType: 'ssh', + initRepo: false, + keyGeneratorType: 'ed25519', + publicKey: 'ssh-ed25519 AAAAC3NzaC1...', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(sshPrefs); + + // Act & Assert + expect(sshPrefs.connectionType).toBe('ssh'); + expect(mockPreferencesService.getPreferences().connectionType).toBe('ssh'); + expect(sshPrefs.publicKey).toBeDefined(); + }); + }); + + describe('Connection Type Switching', () => { + it('should support switching from SSH to HTTPS', () => { + // Arrange + const initialSSHPrefs: SourceControlPreferences = { + connected: false, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: '', + branchReadOnly: false, + branchColor: '#5296D6', + connectionType: 'ssh', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + const updatedHTTPSPrefs: SourceControlPreferences = { + ...initialSSHPrefs, + repositoryUrl: 'https://github.com/user/repo.git', + connectionType: 'https', + }; + + // Act + const isValidTransition = + initialSSHPrefs.connectionType === 'ssh' && updatedHTTPSPrefs.connectionType === 'https'; + + // Assert + expect(isValidTransition).toBe(true); + expect(updatedHTTPSPrefs.repositoryUrl.startsWith('https://')).toBe(true); + }); + + it('should validate repository URL format matches connection type', () => { + // Test cases for URL validation + const testCases = [ + { url: 'https://github.com/user/repo.git', connectionType: 'https', expected: true }, + { url: 'git@github.com:user/repo.git', connectionType: 'ssh', expected: true }, + { url: 'https://github.com/user/repo.git', connectionType: 'ssh', expected: false }, + { url: 'git@github.com:user/repo.git', connectionType: 'https', expected: false }, + ]; + + testCases.forEach(({ url, connectionType, expected }) => { + const isHTTPSUrl = url.startsWith('https://'); + const isSSHUrl = url.startsWith('git@') || url.startsWith('ssh://'); + + const isValid = + (connectionType === 'https' && isHTTPSUrl) || (connectionType === 'ssh' && isSSHUrl); + + expect(isValid).toBe(expected); + }); + }); + }); + + describe('Credential Management', () => { + it('should handle HTTPS credentials securely', async () => { + // Arrange + const credentials = { username: 'testuser', password: 'secret' }; + + jest + .spyOn(mockPreferencesService, 'getDecryptedHttpsCredentials') + .mockResolvedValue(credentials); + + // Act + const retrievedCredentials = await mockPreferencesService.getDecryptedHttpsCredentials(); + + // Assert + expect(retrievedCredentials).toEqual(credentials); + expect(mockPreferencesService.getDecryptedHttpsCredentials).toHaveBeenCalled(); + }); + + it('should clean up credentials on disconnect', async () => { + // Arrange + jest.spyOn(mockPreferencesService, 'deleteHttpsCredentials').mockResolvedValue(); + + // Act + await mockPreferencesService.deleteHttpsCredentials(); + + // Assert + expect(mockPreferencesService.deleteHttpsCredentials).toHaveBeenCalled(); + }); + }); + + describe('URL Encoding and Security', () => { + it('should properly encode URLs with credentials', () => { + // Arrange + const baseUrl = 'https://github.com/user/repo.git'; + const username = 'user@domain.com'; + const password = 'p@ssw0rd!'; + + // Act + const urlWithCredentials = new URL(baseUrl); + urlWithCredentials.username = encodeURIComponent(username); + urlWithCredentials.password = encodeURIComponent(password); + const encodedUrl = urlWithCredentials.toString(); + + // Assert + expect(encodedUrl).toContain(encodeURIComponent(username)); + expect(encodedUrl).toContain('github.com/user/repo.git'); + expect(encodedUrl.startsWith('https://')).toBe(true); + }); + + it('should normalize URLs for comparison', () => { + // Arrange + const urlWithCredentials = 'https://user:token@github.com/user/repo.git'; + const urlWithoutCredentials = 'https://github.com/user/repo.git'; + + // Act + const normalize = (url: string) => { + try { + const urlObj = new URL(url); + urlObj.username = ''; + urlObj.password = ''; + return urlObj.toString(); + } catch { + return url; + } + }; + + const normalized1 = normalize(urlWithCredentials); + const normalized2 = normalize(urlWithoutCredentials); + + // Assert + expect(normalized1).toBe(normalized2); + }); + }); + + describe('Error Handling', () => { + it('should handle connection errors gracefully', async () => { + // Arrange + const error = new Error('Connection failed'); + jest.spyOn(mockGitService, 'initService').mockRejectedValue(error); + + // Act & Assert + const options = { + sourceControlPreferences: { + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + connectionType: 'https' as const, + initRepo: true, + connected: false, + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + keyGeneratorType: 'ed25519' as const, + }, + gitFolder: '/tmp/git', + sshFolder: '/tmp/ssh', + sshKeyName: 'id_ed25519', + }; + + await expect(mockGitService.initService(options)).rejects.toThrow('Connection failed'); + }); + + it('should handle invalid preferences', () => { + // Arrange + const invalidPrefs = { + connectionType: 'https' as const, + repositoryUrl: 'invalid-url', + }; + + // Act + const isValidUrl = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + // Assert + expect(isValidUrl(invalidPrefs.repositoryUrl)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts new file mode 100644 index 0000000000..82a2f186e4 --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts @@ -0,0 +1,308 @@ +import { mock } from 'jest-mock-extended'; +import type { InstanceSettings, Cipher } from 'n8n-core'; +import type { SettingsRepository } from '@n8n/db'; + +import { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; +import type { SourceControlPreferences } from '../types/source-control-preferences'; + +describe('SourceControlPreferencesService - HTTPS functionality', () => { + let sourceControlPreferencesService: SourceControlPreferencesService; + let mockInstanceSettings: InstanceSettings; + let mockCipher: Cipher; + let mockSettingsRepository: SettingsRepository; + + beforeEach(() => { + mockInstanceSettings = mock({ n8nFolder: '/test' }); + mockCipher = { + encrypt: jest.fn(), + decrypt: jest.fn(), + } as unknown as Cipher; + mockSettingsRepository = mock(); + + sourceControlPreferencesService = new SourceControlPreferencesService( + mockInstanceSettings, + mock(), + mockCipher, + mockSettingsRepository, + mock(), + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('saveHttpsCredentials', () => { + it('should encrypt and save HTTPS credentials', async () => { + // Arrange + const username = 'testuser'; + const password = 'testtoken'; + const encryptedUsername = 'encrypted_user'; + const encryptedPassword = 'encrypted_pass'; + + (mockCipher.encrypt as jest.Mock) + .mockReturnValueOnce(encryptedUsername) + .mockReturnValueOnce(encryptedPassword); + + // Act + await sourceControlPreferencesService.saveHttpsCredentials(username, password); + + // Assert + expect(mockCipher.encrypt).toHaveBeenCalledWith(username); + expect(mockCipher.encrypt).toHaveBeenCalledWith(password); + expect(mockSettingsRepository.save).toHaveBeenCalledWith({ + key: 'features.sourceControl.httpsCredentials', + value: JSON.stringify({ + encryptedUsername, + encryptedPassword, + }), + loadOnStartup: true, + }); + }); + + it('should handle encryption errors gracefully', async () => { + // Arrange + const username = 'testuser'; + const password = 'testtoken'; + + jest.spyOn(mockCipher, 'encrypt').mockImplementation(() => { + throw new Error('Encryption failed'); + }); + + // Act & Assert + await expect( + sourceControlPreferencesService.saveHttpsCredentials(username, password), + ).rejects.toThrow('Failed to save HTTPS credentials to database'); + }); + }); + + describe('getDecryptedHttpsCredentials', () => { + it('should decrypt and return HTTPS credentials', async () => { + // Arrange + const encryptedCredentials = { + encryptedUsername: 'encrypted_user', + encryptedPassword: 'encrypted_pass', + }; + const decryptedUsername = 'testuser'; + const decryptedPassword = 'testtoken'; + + jest.spyOn(mockSettingsRepository, 'findByKey').mockResolvedValue({ + key: 'features.sourceControl.httpsCredentials', + value: JSON.stringify(encryptedCredentials), + } as any); + + jest + .spyOn(mockCipher, 'decrypt') + .mockReturnValueOnce(decryptedUsername) + .mockReturnValueOnce(decryptedPassword); + + // Act + const result = await sourceControlPreferencesService.getDecryptedHttpsCredentials(); + + // Assert + expect(result).toEqual({ + username: decryptedUsername, + password: decryptedPassword, + }); + expect(mockCipher.decrypt).toHaveBeenCalledWith(encryptedCredentials.encryptedUsername); + expect(mockCipher.decrypt).toHaveBeenCalledWith(encryptedCredentials.encryptedPassword); + }); + + it('should return null when no credentials are stored', async () => { + // Arrange + jest.spyOn(mockSettingsRepository, 'findByKey').mockResolvedValue(null); + + // Act + const result = await sourceControlPreferencesService.getDecryptedHttpsCredentials(); + + // Assert + expect(result).toBeNull(); + }); + + it('should return null when stored value is invalid JSON', async () => { + // Arrange + jest.spyOn(mockSettingsRepository, 'findByKey').mockResolvedValue({ + key: 'features.sourceControl.httpsCredentials', + value: 'invalid-json', + } as any); + + // Act + const result = await sourceControlPreferencesService.getDecryptedHttpsCredentials(); + + // Assert + expect(result).toBeNull(); + }); + + it('should return null when stored value is null', async () => { + // Arrange + jest.spyOn(mockSettingsRepository, 'findByKey').mockResolvedValue({ + key: 'features.sourceControl.httpsCredentials', + value: null, + } as any); + + // Act + const result = await sourceControlPreferencesService.getDecryptedHttpsCredentials(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('deleteHttpsCredentials', () => { + it('should delete HTTPS credentials from database', async () => { + // Act + await sourceControlPreferencesService.deleteHttpsCredentials(); + + // Assert + expect(mockSettingsRepository.delete).toHaveBeenCalledWith({ + key: 'features.sourceControl.httpsCredentials', + }); + }); + + it('should handle deletion errors gracefully without throwing', async () => { + // Arrange + jest.spyOn(mockSettingsRepository, 'delete').mockRejectedValue(new Error('Delete failed')); + + // Act & Assert - Should not throw + await expect( + sourceControlPreferencesService.deleteHttpsCredentials(), + ).resolves.toBeUndefined(); + }); + }); + + describe('setPreferences', () => { + it('should save HTTPS credentials and sanitize preferences for HTTPS connection', async () => { + // Arrange + const preferences: Partial = { + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + connectionType: 'https', + httpsUsername: 'testuser', + httpsPassword: 'testtoken', + }; + + const saveSpy = jest + .spyOn(sourceControlPreferencesService, 'saveHttpsCredentials') + .mockResolvedValue(undefined); + + jest + .spyOn(mockCipher, 'encrypt') + .mockReturnValueOnce('encrypted_user') + .mockReturnValueOnce('encrypted_pass'); + + // Act + const result = await sourceControlPreferencesService.setPreferences(preferences); + + // Assert + expect(saveSpy).toHaveBeenCalledWith('testuser', 'testtoken'); + expect(result.httpsUsername).toBeUndefined(); + expect(result.httpsPassword).toBeUndefined(); + expect(result.connectionType).toBe('https'); + }); + + it('should not generate SSH key pair for HTTPS connection', async () => { + // Arrange + const preferences: Partial = { + connectionType: 'https', + httpsUsername: 'user', + httpsPassword: 'token', + }; + + jest + .spyOn(sourceControlPreferencesService as any, 'getKeyPairFromDatabase') + .mockResolvedValue(null); + + const mockResult = { publicKey: 'mock-key' } as SourceControlPreferences; + const generateSpy = jest + .spyOn(sourceControlPreferencesService, 'generateAndSaveKeyPair') + .mockResolvedValue(mockResult); + + jest + .spyOn(sourceControlPreferencesService, 'saveHttpsCredentials') + .mockResolvedValue(undefined); + + // Act + await sourceControlPreferencesService.setPreferences(preferences); + + // Assert + expect(generateSpy).not.toHaveBeenCalled(); + }); + + it('should generate SSH key pair for SSH connection when no key pair exists', async () => { + // Arrange + const preferences: Partial = { + connectionType: 'ssh', + }; + + jest + .spyOn(sourceControlPreferencesService as any, 'getKeyPairFromDatabase') + .mockResolvedValue(null); + + const mockResult = { publicKey: 'mock-key' } as SourceControlPreferences; + const generateSpy = jest + .spyOn(sourceControlPreferencesService, 'generateAndSaveKeyPair') + .mockResolvedValue(mockResult); + + // Act + await sourceControlPreferencesService.setPreferences(preferences); + + // Assert + expect(generateSpy).toHaveBeenCalled(); + }); + + it('should generate SSH key pair for undefined connectionType when no key pair exists (backward compatibility)', async () => { + // Arrange + const preferences: Partial = { + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + // connectionType is undefined for backward compatibility + }; + + jest + .spyOn(sourceControlPreferencesService as any, 'getKeyPairFromDatabase') + .mockResolvedValue(null); + + const mockResult = { publicKey: 'mock-key' } as SourceControlPreferences; + const generateSpy = jest + .spyOn(sourceControlPreferencesService, 'generateAndSaveKeyPair') + .mockResolvedValue(mockResult); + + // Act + await sourceControlPreferencesService.setPreferences(preferences); + + // Assert - Should generate SSH key for backward compatibility + expect(generateSpy).toHaveBeenCalled(); + }); + + it('should save HTTPS credentials and sanitize preferences even for SSH connection', async () => { + // Arrange + const preferences: Partial = { + connectionType: 'ssh', + httpsUsername: 'user', // These should be encrypted and removed from preferences + httpsPassword: 'token', + }; + + jest + .spyOn(sourceControlPreferencesService as any, 'getKeyPairFromDatabase') + .mockResolvedValue({}); + + const saveSpy = jest + .spyOn(sourceControlPreferencesService, 'saveHttpsCredentials') + .mockResolvedValue(undefined); + + jest + .spyOn(mockCipher, 'encrypt') + .mockReturnValueOnce('encrypted_user') + .mockReturnValueOnce('encrypted_pass'); + + // Act + const result = await sourceControlPreferencesService.setPreferences(preferences); + + // Assert - HTTPS credentials should always be encrypted and sanitized for security + expect(saveSpy).toHaveBeenCalledWith('user', 'token'); + expect(result.httpsUsername).toBeUndefined(); + expect(result.httpsPassword).toBeUndefined(); + expect(result.connectionType).toBe('ssh'); + }); + }); +}); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts new file mode 100644 index 0000000000..e0eccc7526 --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts @@ -0,0 +1,204 @@ +import { mock } from 'jest-mock-extended'; + +import { SourceControlService } from '../source-control.service.ee'; +import type { SourceControlGitService } from '../source-control-git.service.ee'; +import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; +import type { SourceControlExportService } from '../source-control-export.service.ee'; +import type { SourceControlPreferences } from '../types/source-control-preferences'; + +describe('SourceControlService - disconnect functionality', () => { + let sourceControlService: SourceControlService; + let mockGitService: SourceControlGitService; + let mockPreferencesService: SourceControlPreferencesService; + let mockExportService: SourceControlExportService; + + beforeEach(() => { + mockGitService = mock(); + mockPreferencesService = mock(); + mockExportService = mock(); + + sourceControlService = new SourceControlService( + mock(), + mockGitService, + mockPreferencesService, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + + (sourceControlService as any).sourceControlExportService = mockExportService; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('disconnect', () => { + it('should clean up HTTPS credentials when connection type is HTTPS', async () => { + // Arrange + const httpsPreferences: SourceControlPreferences = { + connectionType: 'https' as const, + repositoryUrl: 'https://github.com/user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(httpsPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockResolvedValue({} as any); + mockPreferencesService.deleteHttpsCredentials = jest.fn().mockResolvedValue(undefined); + jest.spyOn(mockExportService, 'deleteRepositoryFolder').mockResolvedValue(undefined); + + await sourceControlService.disconnect(); + + expect(mockPreferencesService.deleteHttpsCredentials).toHaveBeenCalled(); + expect(mockPreferencesService.setPreferences).toHaveBeenCalledWith({ + connected: false, + branchName: '', + connectionType: 'ssh', + }); + expect(mockExportService.deleteRepositoryFolder).toHaveBeenCalled(); + expect(mockGitService.resetService).toHaveBeenCalled(); + }); + + it('should clean up SSH key pair when connection type is SSH and keepKeyPair is false', async () => { + const sshPreferences: SourceControlPreferences = { + connectionType: 'ssh' as const, + repositoryUrl: 'git@github.com:user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(sshPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockResolvedValue({} as any); + mockPreferencesService.deleteKeyPair = jest.fn().mockResolvedValue(undefined); + jest.spyOn(mockExportService, 'deleteRepositoryFolder').mockResolvedValue(undefined); + + await sourceControlService.disconnect({ keepKeyPair: false }); + + expect(mockPreferencesService.deleteKeyPair).toHaveBeenCalled(); + expect(mockPreferencesService.setPreferences).toHaveBeenCalledWith({ + connected: false, + branchName: '', + connectionType: 'ssh', + }); + expect(mockExportService.deleteRepositoryFolder).toHaveBeenCalled(); + expect(mockGitService.resetService).toHaveBeenCalled(); + }); + + it('should keep SSH key pair when connection type is SSH and keepKeyPair is true', async () => { + const sshPreferences: SourceControlPreferences = { + connectionType: 'ssh' as const, + repositoryUrl: 'git@github.com:user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(sshPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockResolvedValue({} as any); + mockPreferencesService.deleteKeyPair = jest.fn().mockResolvedValue(undefined); + jest.spyOn(mockExportService, 'deleteRepositoryFolder').mockResolvedValue(undefined); + + await sourceControlService.disconnect({ keepKeyPair: true }); + + expect(mockPreferencesService.deleteKeyPair).not.toHaveBeenCalled(); + expect(mockPreferencesService.setPreferences).toHaveBeenCalledWith({ + connected: false, + branchName: '', + connectionType: 'ssh', + }); + expect(mockExportService.deleteRepositoryFolder).toHaveBeenCalled(); + expect(mockGitService.resetService).toHaveBeenCalled(); + }); + + it('should not delete SSH keys when connection type is HTTPS', async () => { + const httpsPreferences: SourceControlPreferences = { + connectionType: 'https' as const, + repositoryUrl: 'https://github.com/user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(httpsPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockResolvedValue({} as any); + mockPreferencesService.deleteHttpsCredentials = jest.fn().mockResolvedValue(undefined); + mockPreferencesService.deleteKeyPair = jest.fn().mockResolvedValue(undefined); + jest.spyOn(mockExportService, 'deleteRepositoryFolder').mockResolvedValue(undefined); + + await sourceControlService.disconnect({ keepKeyPair: false }); + + expect(mockPreferencesService.deleteKeyPair).not.toHaveBeenCalled(); + expect(mockPreferencesService.deleteHttpsCredentials).toHaveBeenCalled(); + }); + + it('should handle errors during disconnect gracefully', async () => { + const httpsPreferences: SourceControlPreferences = { + connectionType: 'https' as const, + repositoryUrl: 'https://github.com/user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(httpsPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockRejectedValue(new Error('DB error')); + + await expect(sourceControlService.disconnect()).rejects.toThrow( + 'Failed to disconnect from source control', + ); + }); + + it('should reset connection type to SSH by default', async () => { + const httpsPreferences: SourceControlPreferences = { + connectionType: 'https' as const, + repositoryUrl: 'https://github.com/user/repo.git', + connected: true, + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: '', + initRepo: false, + keyGeneratorType: 'ed25519', + }; + + jest.spyOn(mockPreferencesService, 'getPreferences').mockReturnValue(httpsPreferences); + jest.spyOn(mockPreferencesService, 'setPreferences').mockResolvedValue({} as any); + mockPreferencesService.deleteHttpsCredentials = jest.fn().mockResolvedValue(undefined); + jest.spyOn(mockExportService, 'deleteRepositoryFolder').mockResolvedValue(undefined); + + await sourceControlService.disconnect(); + + expect(mockPreferencesService.setPreferences).toHaveBeenCalledWith({ + connected: false, + branchName: '', + connectionType: 'ssh', + }); + }); + }); +}); diff --git a/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts index 4d8b84ed01..8bbe77753a 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts @@ -83,7 +83,7 @@ export class SourceControlGitService { sourceControlFoldersExistCheck([gitFolder, sshFolder]); - await this.setGitSshCommand(gitFolder, sshFolder); + await this.setGitCommand(gitFolder, sshFolder); if (!(await this.checkRepositorySetup())) { await (this.git as unknown as SimpleGit).init(); @@ -96,28 +96,11 @@ export class SourceControlGitService { } } - /** - * Update the SSH command with the path to the temp file containing the private key from the DB. - */ - async setGitSshCommand( + async setGitCommand( gitFolder = this.sourceControlPreferencesService.gitFolder, sshFolder = this.sourceControlPreferencesService.sshFolder, ) { - const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath(); - - const sshKnownHosts = path.join(sshFolder, 'known_hosts'); - - // Convert paths to POSIX format for SSH command (works cross-platform) - // Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform - const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/'); - const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/'); - - // Escape double quotes to prevent command injection - const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"'); - const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"'); - - // Quote paths to handle spaces and special characters - const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=no -i "${escapedPrivateKeyPath}"`; + const preferences = this.sourceControlPreferencesService.getPreferences(); this.gitOptions = { baseDir: gitFolder, @@ -128,9 +111,28 @@ export class SourceControlGitService { const { simpleGit } = await import('simple-git'); - this.git = simpleGit(this.gitOptions) - .env('GIT_SSH_COMMAND', sshCommand) - .env('GIT_TERMINAL_PROMPT', '0'); + if (preferences.connectionType === 'https') { + this.git = simpleGit(this.gitOptions).env('GIT_TERMINAL_PROMPT', '0'); + } else { + const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath(); + const sshKnownHosts = path.join(sshFolder, 'known_hosts'); + + // Convert paths to POSIX format for SSH command (works cross-platform) + // Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform + const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/'); + const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/'); + + // Escape double quotes to prevent command injection + const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"'); + const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"'); + + // Quote paths to handle spaces and special characters + const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=no -i "${escapedPrivateKeyPath}"`; + + this.git = simpleGit(this.gitOptions) + .env('GIT_SSH_COMMAND', sshCommand) + .env('GIT_TERMINAL_PROMPT', '0'); + } } resetService() { @@ -158,9 +160,28 @@ export class SourceControlGitService { } try { const remotes = await this.git.getRemotes(true); - const foundRemote = remotes.find( - (e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote, - ); + const foundRemote = remotes.find((e) => { + if (e.name !== SOURCE_CONTROL_ORIGIN) return false; + + // Normalize URLs by removing credentials to safely compare HTTPS URLs + // that may contain username/password authentication details + const normalizeUrl = (url: string) => { + try { + const urlObj = new URL(url); + urlObj.username = ''; + urlObj.password = ''; + return urlObj.toString(); + } catch { + return url; + } + }; + + const remoteNormalized = normalizeUrl(e.refs.push); + const inputNormalized = normalizeUrl(remote); + + return remoteNormalized === inputNormalized; + }); + if (foundRemote) { this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`); return true; @@ -173,10 +194,29 @@ export class SourceControlGitService { return false; } + private async getAuthorizedHttpsRepositoryUrl( + repositoryUrl: string, + connectionType: string | undefined, + ): Promise { + if (connectionType !== 'https') { + return repositoryUrl; + } + + const credentials = await this.sourceControlPreferencesService.getDecryptedHttpsCredentials(); + if (!credentials) { + throw new UnexpectedError('HTTPS connection type specified but no credentials found'); + } + + const urlObj = new URL(repositoryUrl); + urlObj.username = encodeURIComponent(credentials.username); + urlObj.password = encodeURIComponent(credentials.password); + return urlObj.toString(); + } + async initRepository( sourceControlPreferences: Pick< SourceControlPreferences, - 'repositoryUrl' | 'branchName' | 'initRepo' + 'repositoryUrl' | 'branchName' | 'initRepo' | 'connectionType' >, user: User, ): Promise { @@ -190,8 +230,14 @@ export class SourceControlGitService { this.logger.debug(`Git init: ${(error as Error).message}`); } } + + const repositoryUrl = await this.getAuthorizedHttpsRepositoryUrl( + sourceControlPreferences.repositoryUrl, + sourceControlPreferences.connectionType, + ); + try { - await this.git.addRemote(SOURCE_CONTROL_ORIGIN, sourceControlPreferences.repositoryUrl); + await this.git.addRemote(SOURCE_CONTROL_ORIGIN, repositoryUrl); this.logger.debug(`Git remote added: ${sourceControlPreferences.repositoryUrl}`); } catch (error) { if ((error as Error).message.includes('remote origin already exists')) { @@ -323,7 +369,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized (fetch)'); } - await this.setGitSshCommand(); + await this.setGitCommand(); return await this.git.fetch(); } @@ -331,7 +377,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized (pull)'); } - await this.setGitSshCommand(); + await this.setGitCommand(); const params = {}; if (options.ffOnly) { Object.assign(params, { '--ff-only': true }); @@ -349,7 +395,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized ({)'); } - await this.setGitSshCommand(); + await this.setGitCommand(); if (force) { return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']); } diff --git a/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts index 5ebeb322a0..2479d87233 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts @@ -91,6 +91,52 @@ export class SourceControlPreferencesService { return dbKeyPair.publicKey; } + private async getHttpsCredentialsFromDatabase() { + const dbSetting = await this.settingsRepository.findByKey( + 'features.sourceControl.httpsCredentials', + ); + + if (!dbSetting?.value) return null; + + type HttpsCredentials = { encryptedUsername: string; encryptedPassword: string }; + + return jsonParse(dbSetting.value, { fallbackValue: null }); + } + + async getDecryptedHttpsCredentials(): Promise<{ username: string; password: string } | null> { + const credentials = await this.getHttpsCredentialsFromDatabase(); + + if (!credentials) return null; + + return { + username: this.cipher.decrypt(credentials.encryptedUsername), + password: this.cipher.decrypt(credentials.encryptedPassword), + }; + } + + async saveHttpsCredentials(username: string, password: string): Promise { + try { + await this.settingsRepository.save({ + key: 'features.sourceControl.httpsCredentials', + value: JSON.stringify({ + encryptedUsername: this.cipher.encrypt(username), + encryptedPassword: this.cipher.encrypt(password), + }), + loadOnStartup: true, + }); + } catch (error) { + throw new UnexpectedError('Failed to save HTTPS credentials to database', { cause: error }); + } + } + + async deleteHttpsCredentials(): Promise { + try { + await this.settingsRepository.delete({ key: 'features.sourceControl.httpsCredentials' }); + } catch (error) { + this.logger.error('Failed to delete HTTPS credentials from database', { error }); + } + } + async getPrivateKeyPath() { const dbPrivateKey = await this.getPrivateKeyFromDatabase(); @@ -217,9 +263,25 @@ export class SourceControlPreferencesService { ): Promise { const noKeyPair = (await this.getKeyPairFromDatabase()) === null; - if (noKeyPair) await this.generateAndSaveKeyPair(); + // Generate SSH key pair for SSH connections or when connectionType is undefined for backward compatibility + if ( + noKeyPair && + (preferences.connectionType === 'ssh' || preferences.connectionType === undefined) + ) { + await this.generateAndSaveKeyPair(); + } + + const sanitizedPreferences = { ...preferences }; + + if (preferences.httpsUsername && preferences.httpsPassword) { + await this.saveHttpsCredentials(preferences.httpsUsername, preferences.httpsPassword); + } + + delete sanitizedPreferences.httpsUsername; + delete sanitizedPreferences.httpsPassword; + + this.sourceControlPreferences = sanitizedPreferences; - this.sourceControlPreferences = preferences; if (saveToDb) { const settingsValue = JSON.stringify(this._sourceControlPreferences); try { diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 13ec8997ca..c610e24a36 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -126,14 +126,21 @@ export class SourceControlService { async disconnect(options: { keepKeyPair?: boolean } = {}) { try { + const preferences = this.sourceControlPreferencesService.getPreferences(); + await this.sourceControlPreferencesService.setPreferences({ connected: false, branchName: '', + connectionType: 'ssh', }); await this.sourceControlExportService.deleteRepositoryFolder(); - if (!options.keepKeyPair) { + + if (preferences.connectionType === 'https') { + await this.sourceControlPreferencesService.deleteHttpsCredentials(); + } else if (!options.keepKeyPair) { await this.sourceControlPreferencesService.deleteKeyPair(); } + this.gitService.resetService(); return this.sourceControlPreferencesService.sourceControlPreferences; } catch (error) { @@ -212,6 +219,9 @@ export class SourceControlService { await this.initGitService(); } try { + const currentBranch = this.sourceControlPreferencesService.getBranchName(); + await this.gitService.fetch(); + await this.gitService.setBranch(currentBranch); await this.gitService.resetBranch(); await this.gitService.pull(); } catch (error) { diff --git a/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts b/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts index 3965ba4041..61f59d383f 100644 --- a/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts +++ b/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsHexColor, IsOptional, IsString, IsIn } from 'class-validator'; import { KeyPairType } from './key-pair-type'; @@ -34,6 +34,18 @@ export class SourceControlPreferences { @IsString() readonly keyGeneratorType?: KeyPairType; + @IsOptional() + @IsIn(['ssh', 'https']) + connectionType?: 'ssh' | 'https' = 'ssh'; + + @IsOptional() + @IsString() + httpsUsername?: string; + + @IsOptional() + @IsString() + httpsPassword?: string; + static fromJSON(json: Partial): SourceControlPreferences { return new SourceControlPreferences(json); } @@ -49,6 +61,9 @@ export class SourceControlPreferences { branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly, branchColor: preferences.branchColor ?? defaultPreferences.branchColor, keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType, + connectionType: preferences.connectionType ?? defaultPreferences.connectionType, + httpsUsername: preferences.httpsUsername ?? defaultPreferences.httpsUsername, + httpsPassword: preferences.httpsPassword ?? defaultPreferences.httpsPassword, }); } } diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 628a5ea588..e1ac395cf0 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -280,37 +280,38 @@ describe('getInsightsByWorkflow', () => { test('compacted data are are grouped by workflow correctly', async () => { // ARRANGE + const now = DateTime.utc(); for (const workflow of [workflow1, workflow2]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 2 }), + periodStart: now.minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); // last 14 days await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 123, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); // Barely in range insight (should be included) @@ -319,7 +320,7 @@ describe('getInsightsByWorkflow', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 13, hours: 23 }), }); // Out of date range insight (should not be included) @@ -328,7 +329,7 @@ describe('getInsightsByWorkflow', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 14 }), + periodStart: now.minus({ days: 14 }), }); } @@ -373,24 +374,25 @@ describe('getInsightsByWorkflow', () => { test('compacted data are grouped by workflow correctly with sorting', async () => { // ARRANGE + const now = DateTime.utc(); for (const workflow of [workflow1, workflow2]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: workflow === workflow1 ? 2 : 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); } @@ -408,12 +410,13 @@ describe('getInsightsByWorkflow', () => { test('compacted data are grouped by workflow correctly with pagination', async () => { // ARRANGE + const now = DateTime.utc(); for (const workflow of [workflow1, workflow2, workflow3]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); } @@ -478,37 +481,38 @@ describe('getInsightsByTime', () => { test('compacted data are are grouped by time correctly', async () => { // ARRANGE + const now: DateTime = DateTime.utc(); for (const workflow of [workflow1, workflow2]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); // Check that hourly data is grouped together with the previous daily data await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'hour', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 2 }), + periodStart: now.minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: workflow === workflow1 ? 10 : 20, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); // Barely in range insight (should be included) @@ -517,7 +521,7 @@ describe('getInsightsByTime', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 13, hours: 23 }), }); // Out of date range insight (should not be included) @@ -526,7 +530,7 @@ describe('getInsightsByTime', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 14 }), + periodStart: now.minus({ days: 14 }), }); } @@ -537,10 +541,10 @@ describe('getInsightsByTime', () => { expect(byTime).toHaveLength(4); // expect date to be sorted by oldest first - expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO()); - expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); - expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO()); - expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO()); + expect(byTime[0].date).toEqual(now.minus({ days: 14 }).startOf('day').toISO()); + expect(byTime[1].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO()); + expect(byTime[2].date).toEqual(now.minus({ days: 2 }).startOf('day').toISO()); + expect(byTime[3].date).toEqual(now.startOf('day').toISO()); expect(byTime[0].values).toEqual({ total: 2, @@ -581,24 +585,25 @@ describe('getInsightsByTime', () => { test('compacted data with limited insight types are grouped by time correctly', async () => { // ARRANGE + const now: DateTime = DateTime.utc(); for (const workflow of [workflow1, workflow2]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'time_saved_min', value: workflow === workflow1 ? 10 : 20, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); } @@ -613,13 +618,13 @@ describe('getInsightsByTime', () => { expect(byTime).toHaveLength(2); // expect results to contain only failure and time saved insights - expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); + expect(byTime[0].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO()); expect(byTime[0].values).toEqual({ timeSaved: 30, failed: 0, }); - expect(byTime[1].date).toEqual(DateTime.utc().startOf('day').toISO()); + expect(byTime[1].date).toEqual(now.startOf('day').toISO()); expect(byTime[1].values).toEqual({ timeSaved: 0, failed: 4, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index fb5282bb36..dbb629b1ec 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2232,6 +2232,8 @@ "settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}", "settings.sourceControl.description.link": "More info", "settings.sourceControl.gitConfig": "Git configuration", + "settings.sourceControl.connectionType": "Connection Type", + "settings.sourceControl.enterValidHttpsUrl": "Please enter a valid HTTPS URL", "settings.sourceControl.repoUrl": "Git repository URL (SSH)", "settings.sourceControl.repoUrlPlaceholder": "e.g. git{'@'}github.com:my-team/my-repository", "settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid", @@ -2327,6 +2329,20 @@ "settings.sourceControl.docs.using.pushPull.url": "https://docs.n8n.io/source-control-environments/using/push-pull", "settings.sourceControl.error.not.connected.title": "Environments have not been enabled", "settings.sourceControl.error.not.connected.message": "Please head over to environment settings to connect a git repository first to activate this functionality.", + "settings.sourceControl.saved.error": "Error setting branch", + "settings.sourceControl.sshRepoUrl": "SSH Repository URL", + "settings.sourceControl.httpsRepoUrl": "HTTPS Repository URL", + "settings.sourceControl.sshRepoUrlPlaceholder": "git{'@'}github.com:user/repository.git", + "settings.sourceControl.httpsRepoUrlPlaceholder": "https://github.com/user/repository.git", + "settings.sourceControl.sshFormatNotice": "Use SSH format: git{'@'}github.com:user/repository.git", + "settings.sourceControl.httpsFormatNotice": "Use HTTPS format: https://github.com/user/repository.git", + "settings.sourceControl.httpsUsername": "Username", + "settings.sourceControl.httpsUsernamePlaceholder": "Enter your GitHub username", + "settings.sourceControl.httpsPersonalAccessToken": "Personal Access Token", + "settings.sourceControl.httpsPersonalAccessTokenPlaceholder": "Enter your Personal Access Token (PAT)", + "settings.sourceControl.httpsWarningNotice": "{strong} Create a Personal Access Token at GitHub Settings → Developer settings → Personal access tokens → Tokens (classic). Required scopes: {repo} for private repositories or {publicRepo} for public ones.", + "settings.sourceControl.httpsWarningNotice.strong": "Personal Access Token required:", + "settings.sourceControl.httpsCredentialsNotice": "Credentials are securely encrypted and stored locally", "showMessage.cancel": "@:_reusableBaseText.cancel", "showMessage.ok": "OK", "showMessage.showDetails": "Show Details", diff --git a/packages/frontend/editor-ui/src/stores/sourceControl.store.test.ts b/packages/frontend/editor-ui/src/stores/sourceControl.store.test.ts new file mode 100644 index 0000000000..b6cc9284fb --- /dev/null +++ b/packages/frontend/editor-ui/src/stores/sourceControl.store.test.ts @@ -0,0 +1,272 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { vi } from 'vitest'; + +import * as vcApi from '@/api/sourceControl'; +import { useSourceControlStore } from '@/stores/sourceControl.store'; +import type { SourceControlPreferences } from '@/types/sourceControl.types'; +import type { SourceControlledFile } from '@n8n/api-types'; + +vi.mock('@/api/sourceControl'); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: vi.fn(() => ({ + restApiContext: {}, + })), +})); + +describe('useSourceControlStore', () => { + let pinia: ReturnType; + let sourceControlStore: ReturnType; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + sourceControlStore = useSourceControlStore(); + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default preferences', () => { + expect(sourceControlStore.preferences.connectionType).toBe('ssh'); + expect(sourceControlStore.preferences.branchName).toBe(''); + expect(sourceControlStore.preferences.repositoryUrl).toBe(''); + expect(sourceControlStore.preferences.connected).toBe(false); + expect(sourceControlStore.preferences.keyGeneratorType).toBe('ed25519'); + }); + + it('should have ssh key types with labels available', () => { + expect(sourceControlStore.sshKeyTypesWithLabel).toEqual([ + { value: 'ed25519', label: 'ED25519' }, + { value: 'rsa', label: 'RSA' }, + ]); + }); + }); + + describe('savePreferences', () => { + it('should call API with HTTPS credentials', async () => { + const preferences = { + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + connectionType: 'https' as const, + httpsUsername: 'testuser', + httpsPassword: 'testtoken', + }; + + const mockSavePreferences = vi.mocked(vcApi.savePreferences); + mockSavePreferences.mockResolvedValue({} as SourceControlPreferences); + + await sourceControlStore.savePreferences(preferences); + + expect(mockSavePreferences).toHaveBeenCalledWith( + {}, // restApiContext + preferences, + ); + }); + + it('should call API with SSH preferences', async () => { + const preferences = { + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + connectionType: 'ssh' as const, + keyGeneratorType: 'ed25519' as const, + }; + + const mockSavePreferences = vi.mocked(vcApi.savePreferences); + mockSavePreferences.mockResolvedValue({} as SourceControlPreferences); + + await sourceControlStore.savePreferences(preferences); + + expect(mockSavePreferences).toHaveBeenCalledWith( + {}, // restApiContext + preferences, + ); + }); + + it('should update local preferences after successful API call', async () => { + const preferences: SourceControlPreferences = { + repositoryUrl: 'https://github.com/user/repo.git', + branchName: 'main', + connectionType: 'https' as const, + connected: true, + branchColor: '#4f46e5', + branchReadOnly: false, + branches: ['main', 'develop'], + }; + + const mockSavePreferences = vi.mocked(vcApi.savePreferences); + mockSavePreferences.mockResolvedValue(preferences); + + await sourceControlStore.savePreferences(preferences); + + expect(sourceControlStore.preferences.repositoryUrl).toBe(preferences.repositoryUrl); + expect(sourceControlStore.preferences.branchName).toBe(preferences.branchName); + expect(sourceControlStore.preferences.connectionType).toBe(preferences.connectionType); + expect(sourceControlStore.preferences.connected).toBe(preferences.connected); + }); + }); + + describe('generateKeyPair', () => { + it('should call API and update public key', async () => { + const keyType = 'ed25519'; + const mockPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest'; + + const mockGenerateKeyPair = vi.mocked(vcApi.generateKeyPair); + mockGenerateKeyPair.mockResolvedValue('ssh-ed25519 AAAAC3NzaC1lZDI...'); + + const mockGetPreferences = vi.mocked(vcApi.getPreferences); + mockGetPreferences.mockImplementation(async () => { + return { + connected: false, + repositoryUrl: '', + branchReadOnly: false, + branchColor: '#000000', + branches: [], + branchName: '', + publicKey: mockPublicKey, + } satisfies SourceControlPreferences; + }); + + await sourceControlStore.generateKeyPair(keyType); + + expect(mockGenerateKeyPair).toHaveBeenCalledWith({}, keyType); + expect(sourceControlStore.preferences.publicKey).toBe(mockPublicKey); + expect(sourceControlStore.preferences.keyGeneratorType).toBe(keyType); + }); + + it('should handle API errors', async () => { + const mockGenerateKeyPair = vi.mocked(vcApi.generateKeyPair); + mockGenerateKeyPair.mockRejectedValue(new Error('API Error')); + + await expect(sourceControlStore.generateKeyPair('rsa')).rejects.toThrow('API Error'); + }); + }); + + describe('getBranches', () => { + it('should call API and update branches list', async () => { + const mockBranches = { + branches: ['main', 'develop', 'feature/test'], + currentBranch: 'main', + }; + + const mockGetBranches = vi.mocked(vcApi.getBranches); + mockGetBranches.mockResolvedValue(mockBranches); + + await sourceControlStore.getBranches(); + + expect(mockGetBranches).toHaveBeenCalledWith({}); + expect(sourceControlStore.preferences.branches).toEqual(mockBranches.branches); + }); + }); + + describe('disconnect', () => { + it('should call API and reset preferences', async () => { + sourceControlStore.preferences.connected = true; + sourceControlStore.preferences.repositoryUrl = 'https://github.com/user/repo.git'; + sourceControlStore.preferences.branchName = 'main'; + + const mockDisconnect = vi.mocked(vcApi.disconnect); + mockDisconnect.mockResolvedValue('Disconnected successfully'); + + await sourceControlStore.disconnect(false); + + expect(mockDisconnect).toHaveBeenCalledWith({}, false); + expect(sourceControlStore.preferences.connected).toBe(false); + expect(sourceControlStore.preferences.branches).toEqual([]); + }); + }); + + describe('pushWorkfolder', () => { + it('should call API with correct parameters', async () => { + const data = { + commitMessage: 'Test commit', + fileNames: [ + { + id: 'workflow1', + name: 'Test Workflow', + type: 'workflow' as const, + status: 'modified' as const, + location: 'local' as const, + conflict: false, + file: '/path/to/workflow.json', + updatedAt: '2024-01-01T00:00:00.000Z', + pushed: false, + }, + ], + force: false, + }; + + const mockPushWorkfolder = vi.mocked(vcApi.pushWorkfolder); + mockPushWorkfolder.mockResolvedValue(undefined); + + await sourceControlStore.pushWorkfolder(data); + + expect(mockPushWorkfolder).toHaveBeenCalledWith( + {}, // restApiContext + { + force: data.force, + commitMessage: data.commitMessage, + fileNames: data.fileNames, + }, + ); + expect(sourceControlStore.state.commitMessage).toBe(data.commitMessage); + }); + }); + + describe('pullWorkfolder', () => { + it('should call API with correct parameters', async () => { + const force = true; + + const mockResult: SourceControlledFile[] = [ + { + file: 'test.json', + id: 'test-id', + name: 'test-workflow', + type: 'workflow', + status: 'new', + location: 'local', + conflict: false, + updatedAt: '2023-01-01T00:00:00.000Z', + }, + ]; + + const mockPullWorkfolder = vi.mocked(vcApi.pullWorkfolder); + mockPullWorkfolder.mockResolvedValue(mockResult); + + const result = await sourceControlStore.pullWorkfolder(force); + + expect(mockPullWorkfolder).toHaveBeenCalledWith({}, { force }); + expect(result).toEqual(mockResult); + }); + }); + + describe('getAggregatedStatus', () => { + it('should call API and return status', async () => { + const mockStatus: SourceControlledFile[] = [ + { + id: 'workflow1', + name: 'Test Workflow', + type: 'workflow' as const, + status: 'modified' as const, + location: 'local' as const, + conflict: false, + file: '/path/to/workflow.json', + updatedAt: '2024-01-01T00:00:00.000Z', + pushed: false, + }, + ]; + + const mockGetAggregatedStatus = vi.mocked(vcApi.getAggregatedStatus); + mockGetAggregatedStatus.mockResolvedValue(mockStatus); + + const result = await sourceControlStore.getAggregatedStatus(); + + expect(mockGetAggregatedStatus).toHaveBeenCalledWith({}); + expect(result).toEqual(mockStatus); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/stores/sourceControl.store.ts b/packages/frontend/editor-ui/src/stores/sourceControl.store.ts index 5cedc815d7..0fcb15c373 100644 --- a/packages/frontend/editor-ui/src/stores/sourceControl.store.ts +++ b/packages/frontend/editor-ui/src/stores/sourceControl.store.ts @@ -30,6 +30,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => { connected: false, publicKey: '', keyGeneratorType: 'ed25519', + connectionType: 'ssh', }); const state = reactive<{ diff --git a/packages/frontend/editor-ui/src/types/sourceControl.types.ts b/packages/frontend/editor-ui/src/types/sourceControl.types.ts index 1b412ed843..e54b410f0a 100644 --- a/packages/frontend/editor-ui/src/types/sourceControl.types.ts +++ b/packages/frontend/editor-ui/src/types/sourceControl.types.ts @@ -12,6 +12,7 @@ export type SourceControlPreferences = { publicKey?: string; keyGeneratorType?: TupleToUnion; currentBranch?: string; + connectionType?: 'ssh' | 'https'; }; export interface SourceControlStatus { diff --git a/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts b/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts index 8860cf15f0..5081c2918f 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts @@ -150,6 +150,58 @@ describe('SettingsSourceControl', () => { expect(generateKeyPairSpy).toHaveBeenCalledWith('rsa'); }, 10000); + describe('Protocol Selection', () => { + beforeEach(() => { + settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true; + }); + + it('should show SSH-specific fields when SSH protocol is selected', async () => { + await nextTick(); + const { container, getByTestId } = renderComponent({ + pinia, + }); + + await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual('')); + + // SSH should be selected by default + const connectionTypeSelect = getByTestId('source-control-connection-type-select'); + expect(within(connectionTypeSelect).getByDisplayValue('SSH')).toBeInTheDocument(); + + // SSH-specific fields should be visible + expect(getByTestId('source-control-ssh-key-type-select')).toBeInTheDocument(); + expect(getByTestId('source-control-refresh-ssh-key-button')).toBeInTheDocument(); + expect(container.querySelector('input[name="repoUrl"]')).toBeInTheDocument(); + + // HTTPS-specific fields should not be visible + expect(container.querySelector('input[name="httpsUsername"]')).not.toBeInTheDocument(); + expect(container.querySelector('input[name="httpsPassword"]')).not.toBeInTheDocument(); + }); + + it('should show HTTPS-specific fields when HTTPS protocol is selected', async () => { + await nextTick(); + const { container, queryByTestId } = renderComponent({ + pinia, + }); + + await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual('')); + + // Change to HTTPS protocol + const connectionTypeSelect = queryByTestId('source-control-connection-type-select')!; + await userEvent.click(within(connectionTypeSelect).getByRole('combobox')); + await waitFor(() => expect(screen.getByText('HTTPS')).toBeVisible()); + await userEvent.click(screen.getByText('HTTPS')); + + // HTTPS-specific fields should be visible + expect(container.querySelector('input[name="httpsUsername"]')).toBeInTheDocument(); + expect(container.querySelector('input[name="httpsPassword"]')).toBeInTheDocument(); + expect(container.querySelector('input[name="repoUrl"]')).toBeInTheDocument(); + + // SSH-specific fields should not be visible + expect(queryByTestId('source-control-ssh-key-type-select')).not.toBeInTheDocument(); + expect(queryByTestId('source-control-refresh-ssh-key-button')).not.toBeInTheDocument(); + }); + }); + describe('should test repo URLs', () => { beforeEach(() => { settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true; diff --git a/packages/frontend/editor-ui/src/views/SettingsSourceControl.vue b/packages/frontend/editor-ui/src/views/SettingsSourceControl.vue index 50c8b8be22..6510462c14 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSourceControl.vue +++ b/packages/frontend/editor-ui/src/views/SettingsSourceControl.vue @@ -7,12 +7,12 @@ import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper import { useToast } from '@/composables/useToast'; import { MODAL_CONFIRM } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; -import type { SshKeyTypes } from '@/types/sourceControl.types'; +import type { SshKeyTypes, SourceControlPreferences } from '@/types/sourceControl.types'; import type { TupleToUnion } from '@/utils/typeHelpers'; import type { Rule, RuleGroup } from '@n8n/design-system/types'; import { useI18n } from '@n8n/i18n'; import type { Validatable } from '@n8n/design-system'; -import { computed, onMounted, reactive, ref } from 'vue'; +import { computed, onMounted, reactive, ref, watch } from 'vue'; import { I18nT } from 'vue-i18n'; const locale = useI18n(); @@ -24,6 +24,10 @@ const documentTitle = useDocumentTitle(); const loadingService = useLoadingService(); const isConnected = ref(false); +const connectionType = ref<'ssh' | 'https'>('ssh'); +const httpsUsername = ref(''); +const httpsPassword = ref(''); + const branchNameOptions = computed(() => sourceControlStore.preferences.branches.map((branch) => ({ value: branch, @@ -31,13 +35,29 @@ const branchNameOptions = computed(() => })), ); +const connectionTypeOptions = [ + { value: 'ssh', label: 'SSH' }, + { value: 'https', label: 'HTTPS' }, +]; + const onConnect = async () => { loadingService.startLoading(); loadingService.setLoadingText(locale.baseText('settings.sourceControl.loading.connecting')); try { - await sourceControlStore.savePreferences({ + const connectionData: Partial & { + httpsUsername?: string; + httpsPassword?: string; + } = { repositoryUrl: sourceControlStore.preferences.repositoryUrl, - }); + connectionType: connectionType.value, + }; + + if (connectionType.value === 'https') { + connectionData.httpsUsername = httpsUsername.value; + connectionData.httpsPassword = httpsPassword.value; + } + + await sourceControlStore.savePreferences(connectionData); await sourceControlStore.getBranches(); isConnected.value = true; toast.showMessage({ @@ -51,6 +71,11 @@ const onConnect = async () => { loadingService.stopLoading(); }; +const onSubmitConnectionForm = (event: Event) => { + event.preventDefault(); + void onConnect(); +}; + const onDisconnect = async () => { try { const confirmation = await message.confirm( @@ -66,6 +91,8 @@ const onDisconnect = async () => { loadingService.startLoading(); await sourceControlStore.disconnect(true); isConnected.value = false; + httpsUsername.value = ''; + httpsPassword.value = ''; toast.showMessage({ title: locale.baseText('settings.sourceControl.toast.disconnected.title'), message: locale.baseText('settings.sourceControl.toast.disconnected.message'), @@ -91,7 +118,7 @@ const onSave = async () => { type: 'success', }); } catch (error) { - toast.showError(error, 'Error setting branch'); + toast.showError(error, locale.baseText('settings.sourceControl.saved.error')); } loadingService.stopLoading(); }; @@ -111,6 +138,7 @@ const initialize = async () => { await sourceControlStore.getPreferences(); if (sourceControlStore.preferences.connected) { isConnected.value = true; + connectionType.value = sourceControlStore.preferences.connectionType || 'ssh'; void sourceControlStore.getBranches(); } }; @@ -124,27 +152,54 @@ onMounted(async () => { const formValidationStatus = reactive>({ repoUrl: false, keyGeneratorType: false, + httpsUsername: false, + httpsPassword: false, }); function onValidate(key: string, value: boolean) { formValidationStatus[key] = value; } -const repoUrlValidationRules: Array = [ - { name: 'REQUIRED' }, - { - name: 'MATCH_REGEX', - config: { - regex: - /^(?:git@|ssh:\/\/git@|[\w-]+@)(?:[\w.-]+|\[[0-9a-fA-F:]+])(?::\d+)?[:\/][\w\-~.]+(?:\/[\w\-~.]+)*(?:\.git)?(?:\/.*)?$/, - message: locale.baseText('settings.sourceControl.repoUrlInvalid'), - }, - }, -]; +const repoUrlValidationRules = computed>(() => { + const baseRules: Array = [{ name: 'REQUIRED' }]; + + if (connectionType.value === 'ssh') { + baseRules.push({ + name: 'MATCH_REGEX', + config: { + regex: + /^(?:git@|ssh:\/\/git@|[\w-]+@)(?:[\w.-]+|\[[0-9a-fA-F:]+])(?::\d+)?[:\/][\w\-~.]+(?:\/[\w\-~.]+)*(?:\.git)?(?:\/.*)?$/, + message: locale.baseText('settings.sourceControl.repoUrlInvalid'), + }, + }); + } else { + baseRules.push({ + name: 'MATCH_REGEX', + config: { + regex: /^https?:\/\/.+$/, + message: locale.baseText('settings.sourceControl.enterValidHttpsUrl'), + }, + }); + } + + return baseRules; +}); const keyGeneratorTypeValidationRules: Array = [{ name: 'REQUIRED' }]; +const httpsCredentialValidationRules: Array = [{ name: 'REQUIRED' }]; + +const validForConnection = computed(() => { + if (connectionType.value === 'ssh') { + return formValidationStatus.repoUrl; + } else { + return ( + formValidationStatus.repoUrl && + formValidationStatus.httpsUsername && + formValidationStatus.httpsPassword + ); + } +}); -const validForConnection = computed(() => formValidationStatus.repoUrl); const branchNameValidationRules: Array = [{ name: 'REQUIRED' }]; async function refreshSshKey() { @@ -189,6 +244,16 @@ const onSelectSshKeyType = (value: Validatable) => { } sourceControlStore.preferences.keyGeneratorType = sshKeyType; }; + +watch(connectionType, () => { + formValidationStatus.repoUrl = false; + formValidationStatus.httpsUsername = false; + formValidationStatus.httpsPassword = false; + + if (!isConnected.value) { + sourceControlStore.preferences.repositoryUrl = ''; + } +});