mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Add HTTPS protocol support for repository connections (#18250)
Co-authored-by: konstantintieber <konstantin.tieber@n8n.io>
This commit is contained in:
committed by
GitHub
parent
eb389a787b
commit
5c6094dfd7
@@ -54,7 +54,7 @@ describe('SourceControlGitService', () => {
|
||||
const checkoutSpy = jest.spyOn(git, 'checkout');
|
||||
const branchSpy = jest.spyOn(git, 'branch');
|
||||
gitService.git = git;
|
||||
jest.spyOn(gitService, 'setGitSshCommand').mockResolvedValue();
|
||||
jest.spyOn(gitService, 'setGitCommand').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(gitService, 'getBranches')
|
||||
.mockResolvedValue({ currentBranch: '', branches: ['main'] });
|
||||
@@ -71,6 +71,107 @@ describe('SourceControlGitService', () => {
|
||||
expect(branchSpy).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repository URL authorization', () => {
|
||||
it('should use original URL for SSH connection type', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
const mockPreferencesService = mock<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', () => {
|
||||
@@ -124,11 +225,22 @@ describe('SourceControlGitService', () => {
|
||||
|
||||
// Mock the getPrivateKeyPath to return a Windows path
|
||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(windowsPath);
|
||||
// Mock getPreferences to return SSH connection type (required for new functionality)
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
connectionType: 'ssh',
|
||||
connected: true,
|
||||
repositoryUrl: 'git@github.com:user/repo.git',
|
||||
branchName: 'main',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#5296D6',
|
||||
initRepo: false,
|
||||
keyGeneratorType: 'ed25519',
|
||||
});
|
||||
|
||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||
|
||||
// Act
|
||||
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
||||
|
||||
// Assert - verify Windows paths are normalized to POSIX format
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
@@ -154,11 +266,22 @@ describe('SourceControlGitService', () => {
|
||||
|
||||
// Mock the getPrivateKeyPath to return a path with spaces
|
||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(privateKeyPath);
|
||||
// Mock getPreferences to return SSH connection type
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
connectionType: 'ssh',
|
||||
connected: true,
|
||||
repositoryUrl: 'git@github.com:user/repo.git',
|
||||
branchName: 'main',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#5296D6',
|
||||
initRepo: false,
|
||||
keyGeneratorType: 'ed25519',
|
||||
});
|
||||
|
||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||
|
||||
// Act
|
||||
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
||||
|
||||
// Assert - verify paths with spaces are properly quoted
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
@@ -187,11 +310,22 @@ describe('SourceControlGitService', () => {
|
||||
|
||||
// Mock the getPrivateKeyPath to return a path with quotes
|
||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(pathWithQuotes);
|
||||
// Mock getPreferences to return SSH connection type
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
connectionType: 'ssh',
|
||||
connected: true,
|
||||
repositoryUrl: 'git@github.com:user/repo.git',
|
||||
branchName: 'main',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#5296D6',
|
||||
initRepo: false,
|
||||
keyGeneratorType: 'ed25519',
|
||||
});
|
||||
|
||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||
|
||||
// Act
|
||||
await gitService.setGitSshCommand('/git/folder', sshFolder);
|
||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
||||
|
||||
// Assert - verify the SSH command was properly escaped
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { SourceControlGitService } from '../source-control-git.service.ee';
|
||||
import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
|
||||
import type { SourceControlPreferences } from '../types/source-control-preferences';
|
||||
|
||||
const mockSimpleGit = {
|
||||
env: jest.fn(),
|
||||
init: jest.fn().mockResolvedValue(undefined),
|
||||
addRemote: jest.fn().mockResolvedValue(undefined),
|
||||
getRemotes: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockSimpleGit.env.mockReturnValue(mockSimpleGit);
|
||||
|
||||
jest.mock('simple-git', () => ({
|
||||
simpleGit: jest.fn().mockReturnValue(mockSimpleGit),
|
||||
}));
|
||||
|
||||
describe('SourceControlGitService - HTTPS functionality', () => {
|
||||
let sourceControlGitService: SourceControlGitService;
|
||||
let sourceControlPreferencesService: SourceControlPreferencesService;
|
||||
|
||||
const mockPreferences: SourceControlPreferences = {
|
||||
repositoryUrl: 'https://github.com/user/repo.git',
|
||||
branchName: 'main',
|
||||
connectionType: 'https',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#5296D6',
|
||||
connected: false,
|
||||
publicKey: '',
|
||||
initRepo: false,
|
||||
keyGeneratorType: 'ed25519',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sourceControlPreferencesService = mock<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { SourceControlGitService } from '../source-control-git.service.ee';
|
||||
import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
|
||||
import type { SourceControlPreferences } from '../types/source-control-preferences';
|
||||
|
||||
describe('SourceControl Integration Tests', () => {
|
||||
let mockGitService: SourceControlGitService;
|
||||
let mockPreferencesService: SourceControlPreferencesService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGitService = mock<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { InstanceSettings, Cipher } from 'n8n-core';
|
||||
import type { SettingsRepository } from '@n8n/db';
|
||||
|
||||
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
|
||||
import type { SourceControlPreferences } from '../types/source-control-preferences';
|
||||
|
||||
describe('SourceControlPreferencesService - HTTPS functionality', () => {
|
||||
let sourceControlPreferencesService: SourceControlPreferencesService;
|
||||
let mockInstanceSettings: InstanceSettings;
|
||||
let mockCipher: Cipher;
|
||||
let mockSettingsRepository: SettingsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInstanceSettings = mock<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { SourceControlService } from '../source-control.service.ee';
|
||||
import type { SourceControlGitService } from '../source-control-git.service.ee';
|
||||
import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
|
||||
import type { SourceControlExportService } from '../source-control-export.service.ee';
|
||||
import type { SourceControlPreferences } from '../types/source-control-preferences';
|
||||
|
||||
describe('SourceControlService - disconnect functionality', () => {
|
||||
let sourceControlService: SourceControlService;
|
||||
let mockGitService: SourceControlGitService;
|
||||
let mockPreferencesService: SourceControlPreferencesService;
|
||||
let mockExportService: SourceControlExportService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGitService = mock<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]);
|
||||
|
||||
await this.setGitSshCommand(gitFolder, sshFolder);
|
||||
await this.setGitCommand(gitFolder, sshFolder);
|
||||
|
||||
if (!(await this.checkRepositorySetup())) {
|
||||
await (this.git as unknown as SimpleGit).init();
|
||||
@@ -96,28 +96,11 @@ export class SourceControlGitService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the SSH command with the path to the temp file containing the private key from the DB.
|
||||
*/
|
||||
async setGitSshCommand(
|
||||
async setGitCommand(
|
||||
gitFolder = this.sourceControlPreferencesService.gitFolder,
|
||||
sshFolder = this.sourceControlPreferencesService.sshFolder,
|
||||
) {
|
||||
const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath();
|
||||
|
||||
const sshKnownHosts = path.join(sshFolder, 'known_hosts');
|
||||
|
||||
// Convert paths to POSIX format for SSH command (works cross-platform)
|
||||
// Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform
|
||||
const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/');
|
||||
const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/');
|
||||
|
||||
// Escape double quotes to prevent command injection
|
||||
const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"');
|
||||
const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"');
|
||||
|
||||
// Quote paths to handle spaces and special characters
|
||||
const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=no -i "${escapedPrivateKeyPath}"`;
|
||||
const preferences = this.sourceControlPreferencesService.getPreferences();
|
||||
|
||||
this.gitOptions = {
|
||||
baseDir: gitFolder,
|
||||
@@ -128,9 +111,28 @@ export class SourceControlGitService {
|
||||
|
||||
const { simpleGit } = await import('simple-git');
|
||||
|
||||
this.git = simpleGit(this.gitOptions)
|
||||
.env('GIT_SSH_COMMAND', sshCommand)
|
||||
.env('GIT_TERMINAL_PROMPT', '0');
|
||||
if (preferences.connectionType === 'https') {
|
||||
this.git = simpleGit(this.gitOptions).env('GIT_TERMINAL_PROMPT', '0');
|
||||
} else {
|
||||
const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath();
|
||||
const sshKnownHosts = path.join(sshFolder, 'known_hosts');
|
||||
|
||||
// Convert paths to POSIX format for SSH command (works cross-platform)
|
||||
// Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform
|
||||
const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/');
|
||||
const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/');
|
||||
|
||||
// Escape double quotes to prevent command injection
|
||||
const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"');
|
||||
const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"');
|
||||
|
||||
// Quote paths to handle spaces and special characters
|
||||
const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=no -i "${escapedPrivateKeyPath}"`;
|
||||
|
||||
this.git = simpleGit(this.gitOptions)
|
||||
.env('GIT_SSH_COMMAND', sshCommand)
|
||||
.env('GIT_TERMINAL_PROMPT', '0');
|
||||
}
|
||||
}
|
||||
|
||||
resetService() {
|
||||
@@ -158,9 +160,28 @@ export class SourceControlGitService {
|
||||
}
|
||||
try {
|
||||
const remotes = await this.git.getRemotes(true);
|
||||
const foundRemote = remotes.find(
|
||||
(e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote,
|
||||
);
|
||||
const foundRemote = remotes.find((e) => {
|
||||
if (e.name !== SOURCE_CONTROL_ORIGIN) return false;
|
||||
|
||||
// Normalize URLs by removing credentials to safely compare HTTPS URLs
|
||||
// that may contain username/password authentication details
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const remoteNormalized = normalizeUrl(e.refs.push);
|
||||
const inputNormalized = normalizeUrl(remote);
|
||||
|
||||
return remoteNormalized === inputNormalized;
|
||||
});
|
||||
|
||||
if (foundRemote) {
|
||||
this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`);
|
||||
return true;
|
||||
@@ -173,10 +194,29 @@ export class SourceControlGitService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getAuthorizedHttpsRepositoryUrl(
|
||||
repositoryUrl: string,
|
||||
connectionType: string | undefined,
|
||||
): Promise<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(
|
||||
sourceControlPreferences: Pick<
|
||||
SourceControlPreferences,
|
||||
'repositoryUrl' | 'branchName' | 'initRepo'
|
||||
'repositoryUrl' | 'branchName' | 'initRepo' | 'connectionType'
|
||||
>,
|
||||
user: User,
|
||||
): Promise<void> {
|
||||
@@ -190,8 +230,14 @@ export class SourceControlGitService {
|
||||
this.logger.debug(`Git init: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryUrl = await this.getAuthorizedHttpsRepositoryUrl(
|
||||
sourceControlPreferences.repositoryUrl,
|
||||
sourceControlPreferences.connectionType,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.git.addRemote(SOURCE_CONTROL_ORIGIN, sourceControlPreferences.repositoryUrl);
|
||||
await this.git.addRemote(SOURCE_CONTROL_ORIGIN, repositoryUrl);
|
||||
this.logger.debug(`Git remote added: ${sourceControlPreferences.repositoryUrl}`);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('remote origin already exists')) {
|
||||
@@ -323,7 +369,7 @@ export class SourceControlGitService {
|
||||
if (!this.git) {
|
||||
throw new UnexpectedError('Git is not initialized (fetch)');
|
||||
}
|
||||
await this.setGitSshCommand();
|
||||
await this.setGitCommand();
|
||||
return await this.git.fetch();
|
||||
}
|
||||
|
||||
@@ -331,7 +377,7 @@ export class SourceControlGitService {
|
||||
if (!this.git) {
|
||||
throw new UnexpectedError('Git is not initialized (pull)');
|
||||
}
|
||||
await this.setGitSshCommand();
|
||||
await this.setGitCommand();
|
||||
const params = {};
|
||||
if (options.ffOnly) {
|
||||
Object.assign(params, { '--ff-only': true });
|
||||
@@ -349,7 +395,7 @@ export class SourceControlGitService {
|
||||
if (!this.git) {
|
||||
throw new UnexpectedError('Git is not initialized ({)');
|
||||
}
|
||||
await this.setGitSshCommand();
|
||||
await this.setGitCommand();
|
||||
if (force) {
|
||||
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,52 @@ export class SourceControlPreferencesService {
|
||||
return dbKeyPair.publicKey;
|
||||
}
|
||||
|
||||
private async getHttpsCredentialsFromDatabase() {
|
||||
const dbSetting = await this.settingsRepository.findByKey(
|
||||
'features.sourceControl.httpsCredentials',
|
||||
);
|
||||
|
||||
if (!dbSetting?.value) return null;
|
||||
|
||||
type HttpsCredentials = { encryptedUsername: string; encryptedPassword: string };
|
||||
|
||||
return jsonParse<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() {
|
||||
const dbPrivateKey = await this.getPrivateKeyFromDatabase();
|
||||
|
||||
@@ -217,9 +263,25 @@ export class SourceControlPreferencesService {
|
||||
): Promise<SourceControlPreferences> {
|
||||
const noKeyPair = (await this.getKeyPairFromDatabase()) === null;
|
||||
|
||||
if (noKeyPair) await this.generateAndSaveKeyPair();
|
||||
// Generate SSH key pair for SSH connections or when connectionType is undefined for backward compatibility
|
||||
if (
|
||||
noKeyPair &&
|
||||
(preferences.connectionType === 'ssh' || preferences.connectionType === undefined)
|
||||
) {
|
||||
await this.generateAndSaveKeyPair();
|
||||
}
|
||||
|
||||
const sanitizedPreferences = { ...preferences };
|
||||
|
||||
if (preferences.httpsUsername && preferences.httpsPassword) {
|
||||
await this.saveHttpsCredentials(preferences.httpsUsername, preferences.httpsPassword);
|
||||
}
|
||||
|
||||
delete sanitizedPreferences.httpsUsername;
|
||||
delete sanitizedPreferences.httpsPassword;
|
||||
|
||||
this.sourceControlPreferences = sanitizedPreferences;
|
||||
|
||||
this.sourceControlPreferences = preferences;
|
||||
if (saveToDb) {
|
||||
const settingsValue = JSON.stringify(this._sourceControlPreferences);
|
||||
try {
|
||||
|
||||
@@ -126,14 +126,21 @@ export class SourceControlService {
|
||||
|
||||
async disconnect(options: { keepKeyPair?: boolean } = {}) {
|
||||
try {
|
||||
const preferences = this.sourceControlPreferencesService.getPreferences();
|
||||
|
||||
await this.sourceControlPreferencesService.setPreferences({
|
||||
connected: false,
|
||||
branchName: '',
|
||||
connectionType: 'ssh',
|
||||
});
|
||||
await this.sourceControlExportService.deleteRepositoryFolder();
|
||||
if (!options.keepKeyPair) {
|
||||
|
||||
if (preferences.connectionType === 'https') {
|
||||
await this.sourceControlPreferencesService.deleteHttpsCredentials();
|
||||
} else if (!options.keepKeyPair) {
|
||||
await this.sourceControlPreferencesService.deleteKeyPair();
|
||||
}
|
||||
|
||||
this.gitService.resetService();
|
||||
return this.sourceControlPreferencesService.sourceControlPreferences;
|
||||
} catch (error) {
|
||||
@@ -212,6 +219,9 @@ export class SourceControlService {
|
||||
await this.initGitService();
|
||||
}
|
||||
try {
|
||||
const currentBranch = this.sourceControlPreferencesService.getBranchName();
|
||||
await this.gitService.fetch();
|
||||
await this.gitService.setBranch(currentBranch);
|
||||
await this.gitService.resetBranch();
|
||||
await this.gitService.pull();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator';
|
||||
import { IsBoolean, IsHexColor, IsOptional, IsString, IsIn } from 'class-validator';
|
||||
|
||||
import { KeyPairType } from './key-pair-type';
|
||||
|
||||
@@ -34,6 +34,18 @@ export class SourceControlPreferences {
|
||||
@IsString()
|
||||
readonly keyGeneratorType?: KeyPairType;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ssh', 'https'])
|
||||
connectionType?: 'ssh' | 'https' = 'ssh';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
httpsUsername?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
httpsPassword?: string;
|
||||
|
||||
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
|
||||
return new SourceControlPreferences(json);
|
||||
}
|
||||
@@ -49,6 +61,9 @@ export class SourceControlPreferences {
|
||||
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
||||
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
||||
keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType,
|
||||
connectionType: preferences.connectionType ?? defaultPreferences.connectionType,
|
||||
httpsUsername: preferences.httpsUsername ?? defaultPreferences.httpsUsername,
|
||||
httpsPassword: preferences.httpsPassword ?? defaultPreferences.httpsPassword,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,37 +280,38 @@ describe('getInsightsByWorkflow', () => {
|
||||
|
||||
test('compacted data are are grouped by workflow correctly', async () => {
|
||||
// ARRANGE
|
||||
const now = DateTime.utc();
|
||||
for (const workflow of [workflow1, workflow2]) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: workflow === workflow1 ? 1 : 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ day: 2 }),
|
||||
periodStart: now.minus({ day: 2 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
// last 14 days
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'runtime_ms',
|
||||
value: 123,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
|
||||
// Barely in range insight (should be included)
|
||||
@@ -319,7 +320,7 @@ describe('getInsightsByWorkflow', () => {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'hour',
|
||||
periodStart: DateTime.utc().minus({ days: 13, hours: 23 }),
|
||||
periodStart: now.minus({ days: 13, hours: 23 }),
|
||||
});
|
||||
|
||||
// Out of date range insight (should not be included)
|
||||
@@ -328,7 +329,7 @@ describe('getInsightsByWorkflow', () => {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 14 }),
|
||||
periodStart: now.minus({ days: 14 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,24 +374,25 @@ describe('getInsightsByWorkflow', () => {
|
||||
|
||||
test('compacted data are grouped by workflow correctly with sorting', async () => {
|
||||
// ARRANGE
|
||||
const now = DateTime.utc();
|
||||
for (const workflow of [workflow1, workflow2]) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: workflow === workflow1 ? 1 : 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'runtime_ms',
|
||||
value: workflow === workflow1 ? 2 : 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,12 +410,13 @@ describe('getInsightsByWorkflow', () => {
|
||||
|
||||
test('compacted data are grouped by workflow correctly with pagination', async () => {
|
||||
// ARRANGE
|
||||
const now = DateTime.utc();
|
||||
for (const workflow of [workflow1, workflow2, workflow3]) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -478,37 +481,38 @@ describe('getInsightsByTime', () => {
|
||||
|
||||
test('compacted data are are grouped by time correctly', async () => {
|
||||
// ARRANGE
|
||||
const now: DateTime = DateTime.utc();
|
||||
for (const workflow of [workflow1, workflow2]) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: workflow === workflow1 ? 1 : 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
// Check that hourly data is grouped together with the previous daily data
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: 2,
|
||||
periodUnit: 'hour',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ day: 2 }),
|
||||
periodStart: now.minus({ day: 2 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'runtime_ms',
|
||||
value: workflow === workflow1 ? 10 : 20,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
|
||||
// Barely in range insight (should be included)
|
||||
@@ -517,7 +521,7 @@ describe('getInsightsByTime', () => {
|
||||
type: workflow === workflow1 ? 'success' : 'failure',
|
||||
value: 1,
|
||||
periodUnit: 'hour',
|
||||
periodStart: DateTime.utc().minus({ days: 13, hours: 23 }),
|
||||
periodStart: now.minus({ days: 13, hours: 23 }),
|
||||
});
|
||||
|
||||
// Out of date range insight (should not be included)
|
||||
@@ -526,7 +530,7 @@ describe('getInsightsByTime', () => {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 14 }),
|
||||
periodStart: now.minus({ days: 14 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,10 +541,10 @@ describe('getInsightsByTime', () => {
|
||||
expect(byTime).toHaveLength(4);
|
||||
|
||||
// expect date to be sorted by oldest first
|
||||
expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO());
|
||||
expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO());
|
||||
expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO());
|
||||
expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO());
|
||||
expect(byTime[0].date).toEqual(now.minus({ days: 14 }).startOf('day').toISO());
|
||||
expect(byTime[1].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO());
|
||||
expect(byTime[2].date).toEqual(now.minus({ days: 2 }).startOf('day').toISO());
|
||||
expect(byTime[3].date).toEqual(now.startOf('day').toISO());
|
||||
|
||||
expect(byTime[0].values).toEqual({
|
||||
total: 2,
|
||||
@@ -581,24 +585,25 @@ describe('getInsightsByTime', () => {
|
||||
|
||||
test('compacted data with limited insight types are grouped by time correctly', async () => {
|
||||
// ARRANGE
|
||||
const now: DateTime = DateTime.utc();
|
||||
for (const workflow of [workflow1, workflow2]) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: workflow === workflow1 ? 1 : 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
periodStart: now,
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'time_saved_min',
|
||||
value: workflow === workflow1 ? 10 : 20,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
periodStart: now.minus({ days: 10 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -613,13 +618,13 @@ describe('getInsightsByTime', () => {
|
||||
expect(byTime).toHaveLength(2);
|
||||
|
||||
// expect results to contain only failure and time saved insights
|
||||
expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO());
|
||||
expect(byTime[0].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO());
|
||||
expect(byTime[0].values).toEqual({
|
||||
timeSaved: 30,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
expect(byTime[1].date).toEqual(DateTime.utc().startOf('day').toISO());
|
||||
expect(byTime[1].date).toEqual(now.startOf('day').toISO());
|
||||
expect(byTime[1].values).toEqual({
|
||||
timeSaved: 0,
|
||||
failed: 4,
|
||||
|
||||
Reference in New Issue
Block a user