mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
Revert "feat(core): Add HTTPS protocol support for repository connections" (#19471)
This commit is contained in:
committed by
GitHub
parent
10fa3a9b01
commit
4e2682af62
@@ -54,7 +54,7 @@ describe('SourceControlGitService', () => {
|
|||||||
const checkoutSpy = jest.spyOn(git, 'checkout');
|
const checkoutSpy = jest.spyOn(git, 'checkout');
|
||||||
const branchSpy = jest.spyOn(git, 'branch');
|
const branchSpy = jest.spyOn(git, 'branch');
|
||||||
gitService.git = git;
|
gitService.git = git;
|
||||||
jest.spyOn(gitService, 'setGitCommand').mockResolvedValue();
|
jest.spyOn(gitService, 'setGitSshCommand').mockResolvedValue();
|
||||||
jest
|
jest
|
||||||
.spyOn(gitService, 'getBranches')
|
.spyOn(gitService, 'getBranches')
|
||||||
.mockResolvedValue({ currentBranch: '', branches: ['main'] });
|
.mockResolvedValue({ currentBranch: '', branches: ['main'] });
|
||||||
@@ -71,107 +71,6 @@ describe('SourceControlGitService', () => {
|
|||||||
expect(branchSpy).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']);
|
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<SourceControlPreferencesService>();
|
|
||||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
|
||||||
const originUrl = 'git@github.com:user/repo.git';
|
|
||||||
const prefs = mock<SourceControlPreferences>({
|
|
||||||
repositoryUrl: originUrl,
|
|
||||||
connectionType: 'ssh',
|
|
||||||
branchName: 'main',
|
|
||||||
});
|
|
||||||
const user = mock<User>();
|
|
||||||
const git = mock<SimpleGit>();
|
|
||||||
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<SourceControlPreferencesService>();
|
|
||||||
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<SourceControlPreferences>({
|
|
||||||
repositoryUrl: 'https://github.com/user/repo.git',
|
|
||||||
connectionType: 'https',
|
|
||||||
branchName: 'main',
|
|
||||||
});
|
|
||||||
const user = mock<User>();
|
|
||||||
const git = mock<SimpleGit>();
|
|
||||||
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<SourceControlPreferencesService>();
|
|
||||||
mockPreferencesService.getDecryptedHttpsCredentials.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
|
||||||
const prefs = mock<SourceControlPreferences>({
|
|
||||||
repositoryUrl: 'https://github.com/user/repo.git',
|
|
||||||
connectionType: 'https',
|
|
||||||
branchName: 'main',
|
|
||||||
});
|
|
||||||
const user = mock<User>();
|
|
||||||
const git = mock<SimpleGit>();
|
|
||||||
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', () => {
|
describe('getFileContent', () => {
|
||||||
@@ -225,22 +124,11 @@ describe('SourceControlGitService', () => {
|
|||||||
|
|
||||||
// Mock the getPrivateKeyPath to return a Windows path
|
// Mock the getPrivateKeyPath to return a Windows path
|
||||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(windowsPath);
|
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);
|
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||||
|
|
||||||
// Assert - verify Windows paths are normalized to POSIX format
|
// Assert - verify Windows paths are normalized to POSIX format
|
||||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||||
@@ -266,22 +154,11 @@ describe('SourceControlGitService', () => {
|
|||||||
|
|
||||||
// Mock the getPrivateKeyPath to return a path with spaces
|
// Mock the getPrivateKeyPath to return a path with spaces
|
||||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(privateKeyPath);
|
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);
|
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||||
|
|
||||||
// Assert - verify paths with spaces are properly quoted
|
// Assert - verify paths with spaces are properly quoted
|
||||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||||
@@ -310,22 +187,11 @@ describe('SourceControlGitService', () => {
|
|||||||
|
|
||||||
// Mock the getPrivateKeyPath to return a path with quotes
|
// Mock the getPrivateKeyPath to return a path with quotes
|
||||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(pathWithQuotes);
|
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);
|
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||||
|
|
||||||
// Assert - verify the SSH command was properly escaped
|
// Assert - verify the SSH command was properly escaped
|
||||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -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<SourceControlPreferencesService>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<SourceControlGitService>();
|
|
||||||
mockPreferencesService = mock<SourceControlPreferencesService>();
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<InstanceSettings>({ n8nFolder: '/test' });
|
|
||||||
mockCipher = {
|
|
||||||
encrypt: jest.fn(),
|
|
||||||
decrypt: jest.fn(),
|
|
||||||
} as unknown as Cipher;
|
|
||||||
mockSettingsRepository = mock<SettingsRepository>();
|
|
||||||
|
|
||||||
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<SourceControlPreferences> = {
|
|
||||||
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<SourceControlPreferences> = {
|
|
||||||
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<SourceControlPreferences> = {
|
|
||||||
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<SourceControlPreferences> = {
|
|
||||||
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<SourceControlPreferences> = {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<SourceControlGitService>();
|
|
||||||
mockPreferencesService = mock<SourceControlPreferencesService>();
|
|
||||||
mockExportService = mock<SourceControlExportService>();
|
|
||||||
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -83,7 +83,7 @@ export class SourceControlGitService {
|
|||||||
|
|
||||||
sourceControlFoldersExistCheck([gitFolder, sshFolder]);
|
sourceControlFoldersExistCheck([gitFolder, sshFolder]);
|
||||||
|
|
||||||
await this.setGitCommand(gitFolder, sshFolder);
|
await this.setGitSshCommand(gitFolder, sshFolder);
|
||||||
|
|
||||||
if (!(await this.checkRepositorySetup())) {
|
if (!(await this.checkRepositorySetup())) {
|
||||||
await (this.git as unknown as SimpleGit).init();
|
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,
|
gitFolder = this.sourceControlPreferencesService.gitFolder,
|
||||||
sshFolder = this.sourceControlPreferencesService.sshFolder,
|
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 = {
|
this.gitOptions = {
|
||||||
baseDir: gitFolder,
|
baseDir: gitFolder,
|
||||||
@@ -111,28 +128,9 @@ export class SourceControlGitService {
|
|||||||
|
|
||||||
const { simpleGit } = await import('simple-git');
|
const { simpleGit } = await import('simple-git');
|
||||||
|
|
||||||
if (preferences.connectionType === 'https') {
|
this.git = simpleGit(this.gitOptions)
|
||||||
this.git = simpleGit(this.gitOptions).env('GIT_TERMINAL_PROMPT', '0');
|
.env('GIT_SSH_COMMAND', sshCommand)
|
||||||
} else {
|
.env('GIT_TERMINAL_PROMPT', '0');
|
||||||
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() {
|
resetService() {
|
||||||
@@ -160,28 +158,9 @@ export class SourceControlGitService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const remotes = await this.git.getRemotes(true);
|
const remotes = await this.git.getRemotes(true);
|
||||||
const foundRemote = remotes.find((e) => {
|
const foundRemote = remotes.find(
|
||||||
if (e.name !== SOURCE_CONTROL_ORIGIN) return false;
|
(e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote,
|
||||||
|
);
|
||||||
// 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) {
|
if (foundRemote) {
|
||||||
this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`);
|
this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`);
|
||||||
return true;
|
return true;
|
||||||
@@ -194,29 +173,10 @@ export class SourceControlGitService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAuthorizedHttpsRepositoryUrl(
|
|
||||||
repositoryUrl: string,
|
|
||||||
connectionType: string | undefined,
|
|
||||||
): Promise<string> {
|
|
||||||
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(
|
async initRepository(
|
||||||
sourceControlPreferences: Pick<
|
sourceControlPreferences: Pick<
|
||||||
SourceControlPreferences,
|
SourceControlPreferences,
|
||||||
'repositoryUrl' | 'branchName' | 'initRepo' | 'connectionType'
|
'repositoryUrl' | 'branchName' | 'initRepo'
|
||||||
>,
|
>,
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -230,14 +190,8 @@ export class SourceControlGitService {
|
|||||||
this.logger.debug(`Git init: ${(error as Error).message}`);
|
this.logger.debug(`Git init: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositoryUrl = await this.getAuthorizedHttpsRepositoryUrl(
|
|
||||||
sourceControlPreferences.repositoryUrl,
|
|
||||||
sourceControlPreferences.connectionType,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
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}`);
|
this.logger.debug(`Git remote added: ${sourceControlPreferences.repositoryUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error).message.includes('remote origin already exists')) {
|
if ((error as Error).message.includes('remote origin already exists')) {
|
||||||
@@ -369,7 +323,7 @@ export class SourceControlGitService {
|
|||||||
if (!this.git) {
|
if (!this.git) {
|
||||||
throw new UnexpectedError('Git is not initialized (fetch)');
|
throw new UnexpectedError('Git is not initialized (fetch)');
|
||||||
}
|
}
|
||||||
await this.setGitCommand();
|
await this.setGitSshCommand();
|
||||||
return await this.git.fetch();
|
return await this.git.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +331,7 @@ export class SourceControlGitService {
|
|||||||
if (!this.git) {
|
if (!this.git) {
|
||||||
throw new UnexpectedError('Git is not initialized (pull)');
|
throw new UnexpectedError('Git is not initialized (pull)');
|
||||||
}
|
}
|
||||||
await this.setGitCommand();
|
await this.setGitSshCommand();
|
||||||
const params = {};
|
const params = {};
|
||||||
if (options.ffOnly) {
|
if (options.ffOnly) {
|
||||||
Object.assign(params, { '--ff-only': true });
|
Object.assign(params, { '--ff-only': true });
|
||||||
@@ -395,7 +349,7 @@ export class SourceControlGitService {
|
|||||||
if (!this.git) {
|
if (!this.git) {
|
||||||
throw new UnexpectedError('Git is not initialized ({)');
|
throw new UnexpectedError('Git is not initialized ({)');
|
||||||
}
|
}
|
||||||
await this.setGitCommand();
|
await this.setGitSshCommand();
|
||||||
if (force) {
|
if (force) {
|
||||||
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
|
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,52 +91,6 @@ export class SourceControlPreferencesService {
|
|||||||
return dbKeyPair.publicKey;
|
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<HttpsCredentials | null>(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<void> {
|
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
await this.settingsRepository.delete({ key: 'features.sourceControl.httpsCredentials' });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to delete HTTPS credentials from database', { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPrivateKeyPath() {
|
async getPrivateKeyPath() {
|
||||||
const dbPrivateKey = await this.getPrivateKeyFromDatabase();
|
const dbPrivateKey = await this.getPrivateKeyFromDatabase();
|
||||||
|
|
||||||
@@ -263,25 +217,9 @@ export class SourceControlPreferencesService {
|
|||||||
): Promise<SourceControlPreferences> {
|
): Promise<SourceControlPreferences> {
|
||||||
const noKeyPair = (await this.getKeyPairFromDatabase()) === null;
|
const noKeyPair = (await this.getKeyPairFromDatabase()) === null;
|
||||||
|
|
||||||
// Generate SSH key pair for SSH connections or when connectionType is undefined for backward compatibility
|
if (noKeyPair) await this.generateAndSaveKeyPair();
|
||||||
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) {
|
if (saveToDb) {
|
||||||
const settingsValue = JSON.stringify(this._sourceControlPreferences);
|
const settingsValue = JSON.stringify(this._sourceControlPreferences);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -126,21 +126,14 @@ export class SourceControlService {
|
|||||||
|
|
||||||
async disconnect(options: { keepKeyPair?: boolean } = {}) {
|
async disconnect(options: { keepKeyPair?: boolean } = {}) {
|
||||||
try {
|
try {
|
||||||
const preferences = this.sourceControlPreferencesService.getPreferences();
|
|
||||||
|
|
||||||
await this.sourceControlPreferencesService.setPreferences({
|
await this.sourceControlPreferencesService.setPreferences({
|
||||||
connected: false,
|
connected: false,
|
||||||
branchName: '',
|
branchName: '',
|
||||||
connectionType: 'ssh',
|
|
||||||
});
|
});
|
||||||
await this.sourceControlExportService.deleteRepositoryFolder();
|
await this.sourceControlExportService.deleteRepositoryFolder();
|
||||||
|
if (!options.keepKeyPair) {
|
||||||
if (preferences.connectionType === 'https') {
|
|
||||||
await this.sourceControlPreferencesService.deleteHttpsCredentials();
|
|
||||||
} else if (!options.keepKeyPair) {
|
|
||||||
await this.sourceControlPreferencesService.deleteKeyPair();
|
await this.sourceControlPreferencesService.deleteKeyPair();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gitService.resetService();
|
this.gitService.resetService();
|
||||||
return this.sourceControlPreferencesService.sourceControlPreferences;
|
return this.sourceControlPreferencesService.sourceControlPreferences;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -219,9 +212,6 @@ export class SourceControlService {
|
|||||||
await this.initGitService();
|
await this.initGitService();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const currentBranch = this.sourceControlPreferencesService.getBranchName();
|
|
||||||
await this.gitService.fetch();
|
|
||||||
await this.gitService.setBranch(currentBranch);
|
|
||||||
await this.gitService.resetBranch();
|
await this.gitService.resetBranch();
|
||||||
await this.gitService.pull();
|
await this.gitService.pull();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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';
|
import { KeyPairType } from './key-pair-type';
|
||||||
|
|
||||||
@@ -34,18 +34,6 @@ export class SourceControlPreferences {
|
|||||||
@IsString()
|
@IsString()
|
||||||
readonly keyGeneratorType?: KeyPairType;
|
readonly keyGeneratorType?: KeyPairType;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['ssh', 'https'])
|
|
||||||
connectionType?: 'ssh' | 'https' = 'ssh';
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
httpsUsername?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
httpsPassword?: string;
|
|
||||||
|
|
||||||
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
|
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
|
||||||
return new SourceControlPreferences(json);
|
return new SourceControlPreferences(json);
|
||||||
}
|
}
|
||||||
@@ -61,9 +49,6 @@ export class SourceControlPreferences {
|
|||||||
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
||||||
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
||||||
keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType,
|
keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType,
|
||||||
connectionType: preferences.connectionType ?? defaultPreferences.connectionType,
|
|
||||||
httpsUsername: preferences.httpsUsername ?? defaultPreferences.httpsUsername,
|
|
||||||
httpsPassword: preferences.httpsPassword ?? defaultPreferences.httpsPassword,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,38 +280,37 @@ describe('getInsightsByWorkflow', () => {
|
|||||||
|
|
||||||
test('compacted data are are grouped by workflow correctly', async () => {
|
test('compacted data are are grouped by workflow correctly', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const now = DateTime.utc();
|
|
||||||
for (const workflow of [workflow1, workflow2]) {
|
for (const workflow of [workflow1, workflow2]) {
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: workflow === workflow1 ? 1 : 2,
|
value: workflow === workflow1 ? 1 : 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ day: 2 }),
|
periodStart: DateTime.utc().minus({ day: 2 }),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'failure',
|
type: 'failure',
|
||||||
value: 2,
|
value: 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
// last 14 days
|
// last 14 days
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 10 }),
|
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'runtime_ms',
|
type: 'runtime_ms',
|
||||||
value: 123,
|
value: 123,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 10 }),
|
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Barely in range insight (should be included)
|
// Barely in range insight (should be included)
|
||||||
@@ -320,7 +319,7 @@ describe('getInsightsByWorkflow', () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'hour',
|
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)
|
// Out of date range insight (should not be included)
|
||||||
@@ -329,7 +328,7 @@ describe('getInsightsByWorkflow', () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
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 () => {
|
test('compacted data are grouped by workflow correctly with sorting', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const now = DateTime.utc();
|
|
||||||
for (const workflow of [workflow1, workflow2]) {
|
for (const workflow of [workflow1, workflow2]) {
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: workflow === workflow1 ? 1 : 2,
|
value: workflow === workflow1 ? 1 : 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'failure',
|
type: 'failure',
|
||||||
value: 2,
|
value: 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'runtime_ms',
|
type: 'runtime_ms',
|
||||||
value: workflow === workflow1 ? 2 : 1,
|
value: workflow === workflow1 ? 2 : 1,
|
||||||
periodUnit: 'day',
|
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 () => {
|
test('compacted data are grouped by workflow correctly with pagination', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const now = DateTime.utc();
|
|
||||||
for (const workflow of [workflow1, workflow2, workflow3]) {
|
for (const workflow of [workflow1, workflow2, workflow3]) {
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3,
|
value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,38 +478,37 @@ describe('getInsightsByTime', () => {
|
|||||||
|
|
||||||
test('compacted data are are grouped by time correctly', async () => {
|
test('compacted data are are grouped by time correctly', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const now: DateTime = DateTime.utc();
|
|
||||||
for (const workflow of [workflow1, workflow2]) {
|
for (const workflow of [workflow1, workflow2]) {
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: workflow === workflow1 ? 1 : 2,
|
value: workflow === workflow1 ? 1 : 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
// Check that hourly data is grouped together with the previous daily data
|
// Check that hourly data is grouped together with the previous daily data
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'failure',
|
type: 'failure',
|
||||||
value: 2,
|
value: 2,
|
||||||
periodUnit: 'hour',
|
periodUnit: 'hour',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ day: 2 }),
|
periodStart: DateTime.utc().minus({ day: 2 }),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 10 }),
|
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'runtime_ms',
|
type: 'runtime_ms',
|
||||||
value: workflow === workflow1 ? 10 : 20,
|
value: workflow === workflow1 ? 10 : 20,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 10 }),
|
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Barely in range insight (should be included)
|
// Barely in range insight (should be included)
|
||||||
@@ -521,7 +517,7 @@ describe('getInsightsByTime', () => {
|
|||||||
type: workflow === workflow1 ? 'success' : 'failure',
|
type: workflow === workflow1 ? 'success' : 'failure',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'hour',
|
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)
|
// Out of date range insight (should not be included)
|
||||||
@@ -530,7 +526,7 @@ describe('getInsightsByTime', () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
value: 1,
|
value: 1,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 14 }),
|
periodStart: DateTime.utc().minus({ days: 14 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,10 +537,10 @@ describe('getInsightsByTime', () => {
|
|||||||
expect(byTime).toHaveLength(4);
|
expect(byTime).toHaveLength(4);
|
||||||
|
|
||||||
// expect date to be sorted by oldest first
|
// expect date to be sorted by oldest first
|
||||||
expect(byTime[0].date).toEqual(now.minus({ days: 14 }).startOf('day').toISO());
|
expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO());
|
||||||
expect(byTime[1].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO());
|
expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO());
|
||||||
expect(byTime[2].date).toEqual(now.minus({ days: 2 }).startOf('day').toISO());
|
expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO());
|
||||||
expect(byTime[3].date).toEqual(now.startOf('day').toISO());
|
expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO());
|
||||||
|
|
||||||
expect(byTime[0].values).toEqual({
|
expect(byTime[0].values).toEqual({
|
||||||
total: 2,
|
total: 2,
|
||||||
@@ -585,25 +581,24 @@ describe('getInsightsByTime', () => {
|
|||||||
|
|
||||||
test('compacted data with limited insight types are grouped by time correctly', async () => {
|
test('compacted data with limited insight types are grouped by time correctly', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const now: DateTime = DateTime.utc();
|
|
||||||
for (const workflow of [workflow1, workflow2]) {
|
for (const workflow of [workflow1, workflow2]) {
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
value: workflow === workflow1 ? 1 : 2,
|
value: workflow === workflow1 ? 1 : 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'failure',
|
type: 'failure',
|
||||||
value: 2,
|
value: 2,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now,
|
periodStart: DateTime.utc(),
|
||||||
});
|
});
|
||||||
await createCompactedInsightsEvent(workflow, {
|
await createCompactedInsightsEvent(workflow, {
|
||||||
type: 'time_saved_min',
|
type: 'time_saved_min',
|
||||||
value: workflow === workflow1 ? 10 : 20,
|
value: workflow === workflow1 ? 10 : 20,
|
||||||
periodUnit: 'day',
|
periodUnit: 'day',
|
||||||
periodStart: now.minus({ days: 10 }),
|
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,13 +613,13 @@ describe('getInsightsByTime', () => {
|
|||||||
expect(byTime).toHaveLength(2);
|
expect(byTime).toHaveLength(2);
|
||||||
|
|
||||||
// expect results to contain only failure and time saved insights
|
// 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({
|
expect(byTime[0].values).toEqual({
|
||||||
timeSaved: 30,
|
timeSaved: 30,
|
||||||
failed: 0,
|
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({
|
expect(byTime[1].values).toEqual({
|
||||||
timeSaved: 0,
|
timeSaved: 0,
|
||||||
failed: 4,
|
failed: 4,
|
||||||
|
|||||||
@@ -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": "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.description.link": "More info",
|
||||||
"settings.sourceControl.gitConfig": "Git configuration",
|
"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.repoUrl": "Git repository URL (SSH)",
|
||||||
"settings.sourceControl.repoUrlPlaceholder": "e.g. git{'@'}github.com:my-team/my-repository",
|
"settings.sourceControl.repoUrlPlaceholder": "e.g. git{'@'}github.com:my-team/my-repository",
|
||||||
"settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid",
|
"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.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.title": "Environments have not been enabled",
|
||||||
"settings.sourceControl.error.not.connected.message": "Please head over to <a href='/settings/environments'>environment settings</a> to connect a git repository first to activate this functionality.",
|
"settings.sourceControl.error.not.connected.message": "Please head over to <a href='/settings/environments'>environment settings</a> 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.cancel": "@:_reusableBaseText.cancel",
|
||||||
"showMessage.ok": "OK",
|
"showMessage.ok": "OK",
|
||||||
"showMessage.showDetails": "Show Details",
|
"showMessage.showDetails": "Show Details",
|
||||||
|
|||||||
@@ -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<typeof createPinia>;
|
|
||||||
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,6 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
|
|||||||
connected: false,
|
connected: false,
|
||||||
publicKey: '',
|
publicKey: '',
|
||||||
keyGeneratorType: 'ed25519',
|
keyGeneratorType: 'ed25519',
|
||||||
connectionType: 'ssh',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = reactive<{
|
const state = reactive<{
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type SourceControlPreferences = {
|
|||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
|
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
connectionType?: 'ssh' | 'https';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SourceControlStatus {
|
export interface SourceControlStatus {
|
||||||
|
|||||||
@@ -150,58 +150,6 @@ describe('SettingsSourceControl', () => {
|
|||||||
expect(generateKeyPairSpy).toHaveBeenCalledWith('rsa');
|
expect(generateKeyPairSpy).toHaveBeenCalledWith('rsa');
|
||||||
}, 10000);
|
}, 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', () => {
|
describe('should test repo URLs', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { MODAL_CONFIRM } from '@/constants';
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
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 { TupleToUnion } from '@/utils/typeHelpers';
|
||||||
import type { Rule, RuleGroup } from '@n8n/design-system/types';
|
import type { Rule, RuleGroup } from '@n8n/design-system/types';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import type { Validatable } from '@n8n/design-system';
|
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';
|
import { I18nT } from 'vue-i18n';
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
@@ -24,10 +24,6 @@ const documentTitle = useDocumentTitle();
|
|||||||
const loadingService = useLoadingService();
|
const loadingService = useLoadingService();
|
||||||
|
|
||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const connectionType = ref<'ssh' | 'https'>('ssh');
|
|
||||||
const httpsUsername = ref('');
|
|
||||||
const httpsPassword = ref('');
|
|
||||||
|
|
||||||
const branchNameOptions = computed(() =>
|
const branchNameOptions = computed(() =>
|
||||||
sourceControlStore.preferences.branches.map((branch) => ({
|
sourceControlStore.preferences.branches.map((branch) => ({
|
||||||
value: branch,
|
value: branch,
|
||||||
@@ -35,29 +31,13 @@ const branchNameOptions = computed(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionTypeOptions = [
|
|
||||||
{ value: 'ssh', label: 'SSH' },
|
|
||||||
{ value: 'https', label: 'HTTPS' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const onConnect = async () => {
|
const onConnect = async () => {
|
||||||
loadingService.startLoading();
|
loadingService.startLoading();
|
||||||
loadingService.setLoadingText(locale.baseText('settings.sourceControl.loading.connecting'));
|
loadingService.setLoadingText(locale.baseText('settings.sourceControl.loading.connecting'));
|
||||||
try {
|
try {
|
||||||
const connectionData: Partial<SourceControlPreferences> & {
|
await sourceControlStore.savePreferences({
|
||||||
httpsUsername?: string;
|
|
||||||
httpsPassword?: string;
|
|
||||||
} = {
|
|
||||||
repositoryUrl: sourceControlStore.preferences.repositoryUrl,
|
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();
|
await sourceControlStore.getBranches();
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
@@ -71,11 +51,6 @@ const onConnect = async () => {
|
|||||||
loadingService.stopLoading();
|
loadingService.stopLoading();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmitConnectionForm = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void onConnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDisconnect = async () => {
|
const onDisconnect = async () => {
|
||||||
try {
|
try {
|
||||||
const confirmation = await message.confirm(
|
const confirmation = await message.confirm(
|
||||||
@@ -91,8 +66,6 @@ const onDisconnect = async () => {
|
|||||||
loadingService.startLoading();
|
loadingService.startLoading();
|
||||||
await sourceControlStore.disconnect(true);
|
await sourceControlStore.disconnect(true);
|
||||||
isConnected.value = false;
|
isConnected.value = false;
|
||||||
httpsUsername.value = '';
|
|
||||||
httpsPassword.value = '';
|
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
title: locale.baseText('settings.sourceControl.toast.disconnected.title'),
|
title: locale.baseText('settings.sourceControl.toast.disconnected.title'),
|
||||||
message: locale.baseText('settings.sourceControl.toast.disconnected.message'),
|
message: locale.baseText('settings.sourceControl.toast.disconnected.message'),
|
||||||
@@ -118,7 +91,7 @@ const onSave = async () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, locale.baseText('settings.sourceControl.saved.error'));
|
toast.showError(error, 'Error setting branch');
|
||||||
}
|
}
|
||||||
loadingService.stopLoading();
|
loadingService.stopLoading();
|
||||||
};
|
};
|
||||||
@@ -138,7 +111,6 @@ const initialize = async () => {
|
|||||||
await sourceControlStore.getPreferences();
|
await sourceControlStore.getPreferences();
|
||||||
if (sourceControlStore.preferences.connected) {
|
if (sourceControlStore.preferences.connected) {
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
connectionType.value = sourceControlStore.preferences.connectionType || 'ssh';
|
|
||||||
void sourceControlStore.getBranches();
|
void sourceControlStore.getBranches();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -152,54 +124,27 @@ onMounted(async () => {
|
|||||||
const formValidationStatus = reactive<Record<string, boolean>>({
|
const formValidationStatus = reactive<Record<string, boolean>>({
|
||||||
repoUrl: false,
|
repoUrl: false,
|
||||||
keyGeneratorType: false,
|
keyGeneratorType: false,
|
||||||
httpsUsername: false,
|
|
||||||
httpsPassword: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function onValidate(key: string, value: boolean) {
|
function onValidate(key: string, value: boolean) {
|
||||||
formValidationStatus[key] = value;
|
formValidationStatus[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoUrlValidationRules = computed<Array<Rule | RuleGroup>>(() => {
|
const repoUrlValidationRules: Array<Rule | RuleGroup> = [
|
||||||
const baseRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
{ name: 'REQUIRED' },
|
||||||
|
{
|
||||||
if (connectionType.value === 'ssh') {
|
name: 'MATCH_REGEX',
|
||||||
baseRules.push({
|
config: {
|
||||||
name: 'MATCH_REGEX',
|
regex:
|
||||||
config: {
|
/^(?:git@|ssh:\/\/git@|[\w-]+@)(?:[\w.-]+|\[[0-9a-fA-F:]+])(?::\d+)?[:\/][\w\-~.]+(?:\/[\w\-~.]+)*(?:\.git)?(?:\/.*)?$/,
|
||||||
regex:
|
message: locale.baseText('settings.sourceControl.repoUrlInvalid'),
|
||||||
/^(?: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<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
const keyGeneratorTypeValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
||||||
const httpsCredentialValidationRules: Array<Rule | RuleGroup> = [{ 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<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
const branchNameValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
||||||
|
|
||||||
async function refreshSshKey() {
|
async function refreshSshKey() {
|
||||||
@@ -244,16 +189,6 @@ const onSelectSshKeyType = (value: Validatable) => {
|
|||||||
}
|
}
|
||||||
sourceControlStore.preferences.keyGeneratorType = sshKeyType;
|
sourceControlStore.preferences.keyGeneratorType = sshKeyType;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(connectionType, () => {
|
|
||||||
formValidationStatus.repoUrl = false;
|
|
||||||
formValidationStatus.httpsUsername = false;
|
|
||||||
formValidationStatus.httpsPassword = false;
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
sourceControlStore.preferences.repositoryUrl = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -277,185 +212,90 @@ watch(connectionType, () => {
|
|||||||
<n8n-heading size="xlarge" tag="h2" class="mb-s">{{
|
<n8n-heading size="xlarge" tag="h2" class="mb-s">{{
|
||||||
locale.baseText('settings.sourceControl.gitConfig')
|
locale.baseText('settings.sourceControl.gitConfig')
|
||||||
}}</n8n-heading>
|
}}</n8n-heading>
|
||||||
|
<div :class="$style.group">
|
||||||
<form @submit="onSubmitConnectionForm">
|
<label for="repoUrl">{{ locale.baseText('settings.sourceControl.repoUrl') }}</label>
|
||||||
<div v-if="!isConnected" :class="$style.group">
|
<div :class="$style.groupFlex">
|
||||||
<label for="connectionType">{{
|
|
||||||
locale.baseText('settings.sourceControl.connectionType')
|
|
||||||
}}</label>
|
|
||||||
<n8n-form-input
|
<n8n-form-input
|
||||||
id="connectionType"
|
id="repoUrl"
|
||||||
v-model="connectionType"
|
v-model="sourceControlStore.preferences.repositoryUrl"
|
||||||
|
label=""
|
||||||
|
class="ml-0"
|
||||||
|
name="repoUrl"
|
||||||
|
validate-on-blur
|
||||||
|
:validation-rules="repoUrlValidationRules"
|
||||||
|
:disabled="isConnected"
|
||||||
|
:placeholder="locale.baseText('settings.sourceControl.repoUrlPlaceholder')"
|
||||||
|
@validate="(value: boolean) => onValidate('repoUrl', value)"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
v-if="isConnected"
|
||||||
|
:class="$style.disconnectButton"
|
||||||
|
type="tertiary"
|
||||||
|
size="large"
|
||||||
|
icon="trash-2"
|
||||||
|
data-test-id="source-control-disconnect-button"
|
||||||
|
@click="onDisconnect"
|
||||||
|
>{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sourceControlStore.preferences.publicKey" :class="$style.group">
|
||||||
|
<label>{{ locale.baseText('settings.sourceControl.sshKey') }}</label>
|
||||||
|
<div :class="{ [$style.sshInput]: !isConnected }">
|
||||||
|
<n8n-form-input
|
||||||
|
v-if="!isConnected"
|
||||||
|
id="keyGeneratorType"
|
||||||
|
:class="$style.sshKeyTypeSelect"
|
||||||
label=""
|
label=""
|
||||||
type="select"
|
type="select"
|
||||||
name="connectionType"
|
name="keyGeneratorType"
|
||||||
:options="connectionTypeOptions"
|
data-test-id="source-control-ssh-key-type-select"
|
||||||
data-test-id="source-control-connection-type-select"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Repository URL -->
|
|
||||||
<div :class="$style.group">
|
|
||||||
<label for="repoUrl">
|
|
||||||
{{
|
|
||||||
connectionType === 'ssh'
|
|
||||||
? locale.baseText('settings.sourceControl.sshRepoUrl')
|
|
||||||
: locale.baseText('settings.sourceControl.httpsRepoUrl')
|
|
||||||
}}
|
|
||||||
</label>
|
|
||||||
<div :class="$style.groupFlex">
|
|
||||||
<n8n-form-input
|
|
||||||
id="repoUrl"
|
|
||||||
v-model="sourceControlStore.preferences.repositoryUrl"
|
|
||||||
label=""
|
|
||||||
class="ml-0"
|
|
||||||
name="repoUrl"
|
|
||||||
validate-on-blur
|
|
||||||
:validation-rules="repoUrlValidationRules"
|
|
||||||
:disabled="isConnected"
|
|
||||||
:placeholder="
|
|
||||||
connectionType === 'ssh'
|
|
||||||
? locale.baseText('settings.sourceControl.sshRepoUrlPlaceholder')
|
|
||||||
: locale.baseText('settings.sourceControl.httpsRepoUrlPlaceholder')
|
|
||||||
"
|
|
||||||
@validate="(value: boolean) => onValidate('repoUrl', value)"
|
|
||||||
/>
|
|
||||||
<n8n-button
|
|
||||||
v-if="isConnected"
|
|
||||||
:class="$style.disconnectButton"
|
|
||||||
type="tertiary"
|
|
||||||
size="large"
|
|
||||||
icon="trash-2"
|
|
||||||
data-test-id="source-control-disconnect-button"
|
|
||||||
@click="onDisconnect"
|
|
||||||
>{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<n8n-notice v-if="!isConnected && connectionType === 'ssh'" type="info" class="mt-s">
|
|
||||||
{{ locale.baseText('settings.sourceControl.sshFormatNotice') }}
|
|
||||||
</n8n-notice>
|
|
||||||
<n8n-notice v-if="!isConnected && connectionType === 'https'" type="info" class="mt-s">
|
|
||||||
{{ locale.baseText('settings.sourceControl.httpsFormatNotice') }}
|
|
||||||
</n8n-notice>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="connectionType === 'https' && !isConnected" :class="$style.group">
|
|
||||||
<label for="httpsUsername">{{
|
|
||||||
locale.baseText('settings.sourceControl.httpsUsername')
|
|
||||||
}}</label>
|
|
||||||
<n8n-form-input
|
|
||||||
id="httpsUsername"
|
|
||||||
v-model="httpsUsername"
|
|
||||||
label=""
|
|
||||||
name="httpsUsername"
|
|
||||||
type="text"
|
|
||||||
validate-on-blur
|
validate-on-blur
|
||||||
:validation-rules="httpsCredentialValidationRules"
|
:validation-rules="keyGeneratorTypeValidationRules"
|
||||||
:placeholder="locale.baseText('settings.sourceControl.httpsUsernamePlaceholder')"
|
:options="sourceControlStore.sshKeyTypesWithLabel"
|
||||||
@validate="(value: boolean) => onValidate('httpsUsername', value)"
|
:model-value="sourceControlStore.preferences.keyGeneratorType"
|
||||||
|
@validate="(value: boolean) => onValidate('keyGeneratorType', value)"
|
||||||
|
@update:model-value="onSelectSshKeyType"
|
||||||
/>
|
/>
|
||||||
</div>
|
<CopyInput
|
||||||
|
:class="$style.copyInput"
|
||||||
<div v-if="connectionType === 'https' && !isConnected" :class="$style.group">
|
collapse
|
||||||
<label for="httpsPassword">{{
|
size="medium"
|
||||||
locale.baseText('settings.sourceControl.httpsPersonalAccessToken')
|
:value="sourceControlStore.preferences.publicKey"
|
||||||
}}</label>
|
:copy-button-text="locale.baseText('generic.clickToCopy')"
|
||||||
<n8n-form-input
|
|
||||||
id="httpsPassword"
|
|
||||||
v-model="httpsPassword"
|
|
||||||
label=""
|
|
||||||
name="httpsPassword"
|
|
||||||
type="password"
|
|
||||||
validate-on-blur
|
|
||||||
:validation-rules="httpsCredentialValidationRules"
|
|
||||||
:placeholder="
|
|
||||||
locale.baseText('settings.sourceControl.httpsPersonalAccessTokenPlaceholder')
|
|
||||||
"
|
|
||||||
@validate="(value: boolean) => onValidate('httpsPassword', value)"
|
|
||||||
/>
|
/>
|
||||||
<n8n-notice type="warning" class="mt-s">
|
<n8n-button
|
||||||
<I18nT keypath="settings.sourceControl.httpsWarningNotice" tag="span" scope="global">
|
v-if="!isConnected"
|
||||||
<template #strong>
|
size="large"
|
||||||
<strong>{{
|
type="tertiary"
|
||||||
locale.baseText('settings.sourceControl.httpsWarningNotice.strong')
|
icon="refresh-cw"
|
||||||
}}</strong>
|
data-test-id="source-control-refresh-ssh-key-button"
|
||||||
</template>
|
@click="refreshSshKey"
|
||||||
<template #repo>
|
>
|
||||||
<code>repo</code>
|
{{ locale.baseText('settings.sourceControl.refreshSshKey') }}
|
||||||
</template>
|
</n8n-button>
|
||||||
<template #publicRepo>
|
|
||||||
<code>public_repo</code>
|
|
||||||
</template>
|
|
||||||
</I18nT>
|
|
||||||
</n8n-notice>
|
|
||||||
<n8n-notice type="info" class="mt-s">
|
|
||||||
{{ locale.baseText('settings.sourceControl.httpsCredentialsNotice') }}
|
|
||||||
</n8n-notice>
|
|
||||||
</div>
|
</div>
|
||||||
|
<n8n-notice type="info" class="mt-s">
|
||||||
<div
|
<I18nT keypath="settings.sourceControl.sshKeyDescription" tag="span" scope="global">
|
||||||
v-if="connectionType === 'ssh' && sourceControlStore.preferences.publicKey"
|
<template #link>
|
||||||
:class="$style.group"
|
<a
|
||||||
>
|
:href="locale.baseText('settings.sourceControl.docs.setup.ssh.url')"
|
||||||
<label>{{ locale.baseText('settings.sourceControl.sshKey') }}</label>
|
target="_blank"
|
||||||
<div :class="{ [$style.sshInput]: !isConnected }">
|
>{{ locale.baseText('settings.sourceControl.sshKeyDescriptionLink') }}</a
|
||||||
<n8n-form-input
|
>
|
||||||
v-if="!isConnected"
|
</template>
|
||||||
id="keyGeneratorType"
|
</I18nT>
|
||||||
:class="$style.sshKeyTypeSelect"
|
</n8n-notice>
|
||||||
label=""
|
</div>
|
||||||
type="select"
|
<n8n-button
|
||||||
name="keyGeneratorType"
|
v-if="!isConnected"
|
||||||
data-test-id="source-control-ssh-key-type-select"
|
size="large"
|
||||||
validate-on-blur
|
:disabled="!validForConnection"
|
||||||
:validation-rules="keyGeneratorTypeValidationRules"
|
:class="$style.connect"
|
||||||
:options="sourceControlStore.sshKeyTypesWithLabel"
|
data-test-id="source-control-connect-button"
|
||||||
:model-value="sourceControlStore.preferences.keyGeneratorType"
|
@click="onConnect"
|
||||||
@validate="(value: boolean) => onValidate('keyGeneratorType', value)"
|
>{{ locale.baseText('settings.sourceControl.button.connect') }}</n8n-button
|
||||||
@update:model-value="onSelectSshKeyType"
|
>
|
||||||
/>
|
|
||||||
<CopyInput
|
|
||||||
:class="$style.copyInput"
|
|
||||||
collapse
|
|
||||||
size="medium"
|
|
||||||
:value="sourceControlStore.preferences.publicKey"
|
|
||||||
:copy-button-text="locale.baseText('generic.clickToCopy')"
|
|
||||||
/>
|
|
||||||
<n8n-button
|
|
||||||
v-if="!isConnected"
|
|
||||||
size="large"
|
|
||||||
type="tertiary"
|
|
||||||
icon="refresh-cw"
|
|
||||||
data-test-id="source-control-refresh-ssh-key-button"
|
|
||||||
@click="refreshSshKey"
|
|
||||||
>
|
|
||||||
{{ locale.baseText('settings.sourceControl.refreshSshKey') }}
|
|
||||||
</n8n-button>
|
|
||||||
</div>
|
|
||||||
<n8n-notice type="info" class="mt-s">
|
|
||||||
<I18nT keypath="settings.sourceControl.sshKeyDescription" tag="span" scope="global">
|
|
||||||
<template #link>
|
|
||||||
<a
|
|
||||||
:href="locale.baseText('settings.sourceControl.docs.setup.ssh.url')"
|
|
||||||
target="_blank"
|
|
||||||
>{{ locale.baseText('settings.sourceControl.sshKeyDescriptionLink') }}</a
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</I18nT>
|
|
||||||
</n8n-notice>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n8n-button
|
|
||||||
v-if="!isConnected"
|
|
||||||
size="large"
|
|
||||||
:disabled="!validForConnection"
|
|
||||||
:class="$style.connect"
|
|
||||||
data-test-id="source-control-connect-button"
|
|
||||||
@click="onConnect"
|
|
||||||
>{{ locale.baseText('settings.sourceControl.button.connect') }}</n8n-button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div v-if="isConnected" data-test-id="source-control-connected-content">
|
<div v-if="isConnected" data-test-id="source-control-connected-content">
|
||||||
<div :class="$style.group">
|
<div :class="$style.group">
|
||||||
<hr />
|
<hr />
|
||||||
|
|||||||
Reference in New Issue
Block a user