From 4e2682af62b9fe68d35a32913369a4babe36f82e Mon Sep 17 00:00:00 2001 From: Idir Ouhab Meskine Date: Fri, 12 Sep 2025 15:30:50 +0200 Subject: [PATCH] Revert "feat(core): Add HTTPS protocol support for repository connections" (#19471) --- .../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 | 348 +++++------------- 16 files changed, 160 insertions(+), 1856 deletions(-) delete mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts delete mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts delete mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts delete mode 100644 packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts delete 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 3e14c18b6a..2bc0b8d7e8 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, 'setGitCommand').mockResolvedValue(); + jest.spyOn(gitService, 'setGitSshCommand').mockResolvedValue(); jest .spyOn(gitService, 'getBranches') .mockResolvedValue({ currentBranch: '', branches: ['main'] }); @@ -71,107 +71,6 @@ 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', () => { @@ -225,22 +124,11 @@ 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.setGitCommand('/git/folder', sshFolder); + await gitService.setGitSshCommand('/git/folder', sshFolder); // Assert - verify Windows paths are normalized to POSIX format expect(mockGitInstance.env).toHaveBeenCalledWith( @@ -266,22 +154,11 @@ 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.setGitCommand('/git/folder', sshFolder); + await gitService.setGitSshCommand('/git/folder', sshFolder); // Assert - verify paths with spaces are properly quoted expect(mockGitInstance.env).toHaveBeenCalledWith( @@ -310,22 +187,11 @@ 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.setGitCommand('/git/folder', sshFolder); + await gitService.setGitSshCommand('/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 deleted file mode 100644 index 1c8739347f..0000000000 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-https.service.ee.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index 654ac18eb7..0000000000 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-integration.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -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 deleted file mode 100644 index 82a2f186e4..0000000000 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-preferences-https.service.ee.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -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 deleted file mode 100644 index e0eccc7526..0000000000 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-service-disconnect.ee.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 8bbe77753a..4d8b84ed01 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.setGitCommand(gitFolder, sshFolder); + await this.setGitSshCommand(gitFolder, sshFolder); if (!(await this.checkRepositorySetup())) { await (this.git as unknown as SimpleGit).init(); @@ -96,11 +96,28 @@ export class SourceControlGitService { } } - async setGitCommand( + /** + * Update the SSH command with the path to the temp file containing the private key from the DB. + */ + async setGitSshCommand( gitFolder = this.sourceControlPreferencesService.gitFolder, sshFolder = this.sourceControlPreferencesService.sshFolder, ) { - const preferences = this.sourceControlPreferencesService.getPreferences(); + 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.gitOptions = { baseDir: gitFolder, @@ -111,28 +128,9 @@ export class SourceControlGitService { const { simpleGit } = await import('simple-git'); - 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'); - } + this.git = simpleGit(this.gitOptions) + .env('GIT_SSH_COMMAND', sshCommand) + .env('GIT_TERMINAL_PROMPT', '0'); } resetService() { @@ -160,28 +158,9 @@ export class SourceControlGitService { } try { const remotes = await this.git.getRemotes(true); - 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; - }); - + const foundRemote = remotes.find( + (e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote, + ); if (foundRemote) { this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`); return true; @@ -194,29 +173,10 @@ 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' | 'connectionType' + 'repositoryUrl' | 'branchName' | 'initRepo' >, user: User, ): Promise { @@ -230,14 +190,8 @@ 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, repositoryUrl); + await this.git.addRemote(SOURCE_CONTROL_ORIGIN, sourceControlPreferences.repositoryUrl); this.logger.debug(`Git remote added: ${sourceControlPreferences.repositoryUrl}`); } catch (error) { if ((error as Error).message.includes('remote origin already exists')) { @@ -369,7 +323,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized (fetch)'); } - await this.setGitCommand(); + await this.setGitSshCommand(); return await this.git.fetch(); } @@ -377,7 +331,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized (pull)'); } - await this.setGitCommand(); + await this.setGitSshCommand(); const params = {}; if (options.ffOnly) { Object.assign(params, { '--ff-only': true }); @@ -395,7 +349,7 @@ export class SourceControlGitService { if (!this.git) { throw new UnexpectedError('Git is not initialized ({)'); } - await this.setGitCommand(); + await this.setGitSshCommand(); 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 2479d87233..5ebeb322a0 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,52 +91,6 @@ 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(); @@ -263,25 +217,9 @@ export class SourceControlPreferencesService { ): Promise { const noKeyPair = (await this.getKeyPairFromDatabase()) === null; - // 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; + if (noKeyPair) await this.generateAndSaveKeyPair(); + 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 c610e24a36..13ec8997ca 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,21 +126,14 @@ 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 (preferences.connectionType === 'https') { - await this.sourceControlPreferencesService.deleteHttpsCredentials(); - } else if (!options.keepKeyPair) { + if (!options.keepKeyPair) { await this.sourceControlPreferencesService.deleteKeyPair(); } - this.gitService.resetService(); return this.sourceControlPreferencesService.sourceControlPreferences; } catch (error) { @@ -219,9 +212,6 @@ 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 61f59d383f..3965ba4041 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, IsIn } from 'class-validator'; +import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator'; import { KeyPairType } from './key-pair-type'; @@ -34,18 +34,6 @@ 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); } @@ -61,9 +49,6 @@ 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 e1ac395cf0..628a5ea588 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -280,38 +280,37 @@ 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: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ day: 2 }), + periodStart: DateTime.utc().minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: now, + periodStart: DateTime.utc(), }); // last 14 days await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 123, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); // Barely in range insight (should be included) @@ -320,7 +319,7 @@ describe('getInsightsByWorkflow', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), }); // Out of date range insight (should not be included) @@ -329,7 +328,7 @@ describe('getInsightsByWorkflow', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: DateTime.utc().minus({ days: 14 }), }); } @@ -374,25 +373,24 @@ 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: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: workflow === workflow1 ? 2 : 1, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); } @@ -410,13 +408,12 @@ 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: now, + periodStart: DateTime.utc(), }); } @@ -481,38 +478,37 @@ 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: now, + periodStart: DateTime.utc(), }); // Check that hourly data is grouped together with the previous daily data await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'hour', - periodStart: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ day: 2 }), + periodStart: DateTime.utc().minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: workflow === workflow1 ? 10 : 20, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); // Barely in range insight (should be included) @@ -521,7 +517,7 @@ describe('getInsightsByTime', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), }); // Out of date range insight (should not be included) @@ -530,7 +526,7 @@ describe('getInsightsByTime', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: DateTime.utc().minus({ days: 14 }), }); } @@ -541,10 +537,10 @@ describe('getInsightsByTime', () => { expect(byTime).toHaveLength(4); // expect date to be sorted by oldest first - 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].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].values).toEqual({ total: 2, @@ -585,25 +581,24 @@ 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: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: now, + periodStart: DateTime.utc(), }); await createCompactedInsightsEvent(workflow, { type: 'time_saved_min', value: workflow === workflow1 ? 10 : 20, periodUnit: 'day', - periodStart: now.minus({ days: 10 }), + periodStart: DateTime.utc().minus({ days: 10 }), }); } @@ -618,13 +613,13 @@ describe('getInsightsByTime', () => { expect(byTime).toHaveLength(2); // expect results to contain only failure and time saved insights - expect(byTime[0].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO()); + expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); expect(byTime[0].values).toEqual({ timeSaved: 30, failed: 0, }); - expect(byTime[1].date).toEqual(now.startOf('day').toISO()); + expect(byTime[1].date).toEqual(DateTime.utc().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 af8d1789db..d24b8a2133 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2230,8 +2230,6 @@ "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,20 +2325,6 @@ "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 deleted file mode 100644 index b6cc9284fb..0000000000 --- a/packages/frontend/editor-ui/src/stores/sourceControl.store.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -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 0fcb15c373..5cedc815d7 100644 --- a/packages/frontend/editor-ui/src/stores/sourceControl.store.ts +++ b/packages/frontend/editor-ui/src/stores/sourceControl.store.ts @@ -30,7 +30,6 @@ 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 e54b410f0a..1b412ed843 100644 --- a/packages/frontend/editor-ui/src/types/sourceControl.types.ts +++ b/packages/frontend/editor-ui/src/types/sourceControl.types.ts @@ -12,7 +12,6 @@ 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 5081c2918f..8860cf15f0 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsSourceControl.test.ts @@ -150,58 +150,6 @@ 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 6510462c14..50c8b8be22 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, SourceControlPreferences } from '@/types/sourceControl.types'; +import type { SshKeyTypes } 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, watch } from 'vue'; +import { computed, onMounted, reactive, ref } from 'vue'; import { I18nT } from 'vue-i18n'; const locale = useI18n(); @@ -24,10 +24,6 @@ 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, @@ -35,29 +31,13 @@ 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 { - const connectionData: Partial & { - httpsUsername?: string; - httpsPassword?: string; - } = { + await sourceControlStore.savePreferences({ 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({ @@ -71,11 +51,6 @@ const onConnect = async () => { loadingService.stopLoading(); }; -const onSubmitConnectionForm = (event: Event) => { - event.preventDefault(); - void onConnect(); -}; - const onDisconnect = async () => { try { const confirmation = await message.confirm( @@ -91,8 +66,6 @@ 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'), @@ -118,7 +91,7 @@ const onSave = async () => { type: 'success', }); } catch (error) { - toast.showError(error, locale.baseText('settings.sourceControl.saved.error')); + toast.showError(error, 'Error setting branch'); } loadingService.stopLoading(); }; @@ -138,7 +111,6 @@ const initialize = async () => { await sourceControlStore.getPreferences(); if (sourceControlStore.preferences.connected) { isConnected.value = true; - connectionType.value = sourceControlStore.preferences.connectionType || 'ssh'; void sourceControlStore.getBranches(); } }; @@ -152,54 +124,27 @@ onMounted(async () => { const formValidationStatus = reactive>({ repoUrl: false, keyGeneratorType: false, - httpsUsername: false, - httpsPassword: false, }); function onValidate(key: string, value: boolean) { formValidationStatus[key] = value; } -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 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 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() { @@ -244,16 +189,6 @@ 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 = ''; - } -});