mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 06:45:26 +02:00
Co-authored-by: Irénée <irenee.ajeneza@n8n.io> Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
651 lines
19 KiB
TypeScript
651 lines
19 KiB
TypeScript
import type { SourceControlledFile } from '@n8n/api-types';
|
|
import {
|
|
createTeamProject,
|
|
createWorkflowWithHistory,
|
|
mockInstance,
|
|
testDb,
|
|
} from '@n8n/backend-test-utils';
|
|
import {
|
|
CredentialsEntity,
|
|
CredentialsRepository,
|
|
GLOBAL_ADMIN_ROLE,
|
|
type Project,
|
|
ProjectRepository,
|
|
SharedCredentialsRepository,
|
|
type User,
|
|
} from '@n8n/db';
|
|
import { Container } from '@n8n/di';
|
|
import { Cipher, InstanceSettings } from 'n8n-core';
|
|
import { readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { SourceControlExportService } from '@/modules/source-control.ee/source-control-export.service.ee';
|
|
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
|
|
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
|
|
|
|
import { createCredentials } from '../shared/db/credentials';
|
|
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
|
|
import { createUser } from '../shared/db/users';
|
|
|
|
// Mock file system operations
|
|
jest.mock('node:fs/promises');
|
|
|
|
describe('SourceControlExportService Integration', () => {
|
|
let exportService: SourceControlExportService;
|
|
let sourceControlContextFactory: SourceControlContextFactory;
|
|
let testUser: User;
|
|
let personalProject: Project;
|
|
let teamProject: Project;
|
|
let exportDirectory: string;
|
|
|
|
// Repositories
|
|
let credentialsRepository: CredentialsRepository;
|
|
let sharedCredentialsRepository: SharedCredentialsRepository;
|
|
let projectRepository: ProjectRepository;
|
|
|
|
// Mocked functions
|
|
let mockFsWriteFile: jest.MockedFunction<typeof fsWriteFile>;
|
|
let mockFsReadFile: jest.MockedFunction<typeof fsReadFile>;
|
|
|
|
beforeAll(async () => {
|
|
await testDb.init();
|
|
|
|
// Get repositories
|
|
credentialsRepository = Container.get(CredentialsRepository);
|
|
sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
|
|
projectRepository = Container.get(ProjectRepository);
|
|
|
|
// Create test user
|
|
testUser = await createUser({ role: GLOBAL_ADMIN_ROLE });
|
|
personalProject = await projectRepository.getPersonalProjectForUserOrFail(testUser.id);
|
|
teamProject = await createTeamProject('Test Team Project', testUser);
|
|
|
|
// Setup export directory for testing (no longer creating real directories)
|
|
exportDirectory = path.join(process.cwd(), 'test-exports-' + uuid());
|
|
|
|
// Mock the instance settings to use our test directory
|
|
mockInstance(InstanceSettings, {
|
|
n8nFolder: exportDirectory,
|
|
});
|
|
|
|
// Get the services from container (this will use real dependencies)
|
|
exportService = Container.get(SourceControlExportService);
|
|
sourceControlContextFactory = Container.get(SourceControlContextFactory);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testDb.terminate();
|
|
// No cleanup needed since we're not creating real directories
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Setup mocks before each test
|
|
mockFsWriteFile = jest.mocked(fsWriteFile);
|
|
mockFsWriteFile.mockClear();
|
|
mockFsWriteFile.mockResolvedValue();
|
|
|
|
mockFsReadFile = jest.mocked(fsReadFile);
|
|
mockFsReadFile.mockClear();
|
|
// Default to empty tags file
|
|
mockFsReadFile.mockResolvedValue(JSON.stringify({ tags: [], mappings: [] }));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clear test data between tests
|
|
await testDb.truncate([
|
|
'SharedCredentials',
|
|
'CredentialsEntity',
|
|
'SharedWorkflow',
|
|
'WorkflowEntity',
|
|
'WorkflowTagMapping',
|
|
'TagEntity',
|
|
]);
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('exportCredentialsToWorkFolder', () => {
|
|
// Helper to get the expected file path for a credential
|
|
const getExpectedCredentialPath = (credentialId: string): string => {
|
|
return path.join(exportDirectory, 'git', 'credential_stubs', `${credentialId}.json`);
|
|
};
|
|
|
|
// Helper to parse the JSON content that was written to a file
|
|
const getWrittenCredentialData = (credentialId: string): ExportableCredential => {
|
|
const expectedPath = getExpectedCredentialPath(credentialId);
|
|
const writeCall = mockFsWriteFile.mock.calls.find(([filePath]) => filePath === expectedPath);
|
|
if (!writeCall) {
|
|
throw new Error(
|
|
`No write call found for credential ${credentialId} at path ${expectedPath}`,
|
|
);
|
|
}
|
|
const [, content] = writeCall;
|
|
return JSON.parse(content as string);
|
|
};
|
|
|
|
it('should export personal project credentials with correct owner information', async () => {
|
|
// Arrange
|
|
const credentialData = {
|
|
clientId: 'test-client-id',
|
|
clientSecret: 'test-secret',
|
|
apiKey: 'test-api-key',
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Test Personal Credential',
|
|
type: 'testCredentialType',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
},
|
|
personalProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.missingIds).toEqual([]);
|
|
|
|
// Verify file write was called with correct path and content
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
const expectedPath = getExpectedCredentialPath(credential.id);
|
|
expect(mockFsWriteFile).toHaveBeenCalledWith(
|
|
expectedPath,
|
|
expect.stringContaining('"name": "Test Personal Credential"'),
|
|
);
|
|
|
|
// Verify the exported credential content
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
expect(exportedCredential).toMatchObject({
|
|
id: credential.id,
|
|
name: 'Test Personal Credential',
|
|
type: 'testCredentialType',
|
|
data: {
|
|
clientId: '',
|
|
clientSecret: '',
|
|
apiKey: '',
|
|
},
|
|
ownedBy: {
|
|
type: 'personal',
|
|
projectId: personalProject.id,
|
|
projectName: personalProject.name,
|
|
personalEmail: testUser.email,
|
|
},
|
|
});
|
|
|
|
// Verify sensitive data was removed
|
|
expect(exportedCredential.data.clientId).toBe('');
|
|
expect(exportedCredential.data.clientSecret).toBe('');
|
|
expect(exportedCredential.data.apiKey).toBe('');
|
|
});
|
|
|
|
it('should export team project credentials with correct team information', async () => {
|
|
// Arrange
|
|
|
|
const credentialData = {
|
|
serverUrl: 'https://example.com',
|
|
token: 'secret-token',
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Test Team Credential',
|
|
type: 'teamCredentialType',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
},
|
|
teamProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.missingIds).toEqual([]);
|
|
|
|
// Verify file write was called
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
const expectedPath = getExpectedCredentialPath(credential.id);
|
|
expect(mockFsWriteFile).toHaveBeenCalledWith(
|
|
expectedPath,
|
|
expect.stringContaining('"name": "Test Team Credential"'),
|
|
);
|
|
|
|
// Verify file was created and has correct content
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
|
|
expect(exportedCredential).toMatchObject({
|
|
id: credential.id,
|
|
name: 'Test Team Credential',
|
|
type: 'teamCredentialType',
|
|
data: {
|
|
serverUrl: '',
|
|
token: '',
|
|
},
|
|
ownedBy: {
|
|
type: 'team',
|
|
teamId: teamProject.id,
|
|
teamName: teamProject.name,
|
|
},
|
|
});
|
|
|
|
// Verify sensitive data was removed
|
|
expect(exportedCredential.data.serverUrl).toBe('');
|
|
expect(exportedCredential.data.token).toBe('');
|
|
});
|
|
|
|
it('should handle missing credentials and return correct missingIds', async () => {
|
|
// Arrange
|
|
|
|
const nonExistentCredentialId = uuid();
|
|
const candidates = [
|
|
{ id: nonExistentCredentialId },
|
|
{ id: 'another-missing-id' },
|
|
] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(0);
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.missingIds).toEqual([nonExistentCredentialId, 'another-missing-id']);
|
|
|
|
// Verify no file writes were attempted
|
|
expect(mockFsWriteFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should correctly handle partial missing credentials', async () => {
|
|
// Arrange
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Existing Credential',
|
|
type: 'existingType',
|
|
data: Container.get(Cipher).encrypt({ apiKey: 'test' }),
|
|
},
|
|
personalProject,
|
|
);
|
|
|
|
const nonExistentId = uuid();
|
|
const candidates = [{ id: credential.id }, { id: nonExistentId }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.missingIds).toEqual([nonExistentId]);
|
|
|
|
// Verify only one file write was attempted (for the existing credential)
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify the existing credential was exported correctly
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
expect(exportedCredential.name).toBe('Existing Credential');
|
|
});
|
|
|
|
it('should handle role access correctly', async () => {
|
|
// Arrange
|
|
|
|
// Create a credential with explicit role setup
|
|
const credential = new CredentialsEntity();
|
|
Object.assign(credential, {
|
|
name: 'Role Access Test Credential',
|
|
type: 'roleTestType',
|
|
data: Container.get(Cipher).encrypt({ testField: 'test-value' }),
|
|
});
|
|
|
|
const savedCredential = await credentialsRepository.save(credential);
|
|
|
|
// Create the shared credential relationship
|
|
await sharedCredentialsRepository.save(
|
|
sharedCredentialsRepository.create({
|
|
credentials: savedCredential,
|
|
project: personalProject,
|
|
role: 'credential:owner',
|
|
}),
|
|
);
|
|
|
|
const candidates = [{ id: savedCredential.id }] as SourceControlledFile[];
|
|
|
|
// Act & Assert - This should not throw an error accessing pr.role.slug
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
expect(result.count).toBe(1);
|
|
expect(result.files).toHaveLength(1);
|
|
|
|
// Verify file write was called
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify the role access worked and owner information is correct
|
|
const exportedCredential = getWrittenCredentialData(savedCredential.id);
|
|
expect(exportedCredential.ownedBy).toEqual({
|
|
type: 'personal',
|
|
projectId: personalProject.id,
|
|
projectName: personalProject.name,
|
|
personalEmail: testUser.email,
|
|
});
|
|
});
|
|
|
|
it('should handle OAuth credentials and exclude oauthTokenData', async () => {
|
|
// Arrange
|
|
|
|
const credentialData = {
|
|
clientId: 'oauth-client-id',
|
|
clientSecret: 'oauth-secret',
|
|
accessTokenUrl: 'https://oauth.example.com/token',
|
|
oauthTokenData: {
|
|
access_token: 'secret-access-token',
|
|
refresh_token: 'secret-refresh-token',
|
|
expires_in: 3600,
|
|
},
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'OAuth Test Credential',
|
|
type: 'oauth2Credential',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
},
|
|
personalProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
|
|
// Verify file write was called
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
|
|
// Verify oauthTokenData is excluded
|
|
expect(exportedCredential.data).not.toHaveProperty('oauthTokenData');
|
|
|
|
// Verify other fields are present but sanitized
|
|
expect(exportedCredential.data).toMatchObject({
|
|
clientId: '',
|
|
clientSecret: '',
|
|
accessTokenUrl: '',
|
|
});
|
|
});
|
|
|
|
it('should handle credentials with nested data objects', async () => {
|
|
// Arrange
|
|
|
|
const credentialData = {
|
|
connection: {
|
|
host: 'db.example.com',
|
|
port: 5432,
|
|
username: 'user',
|
|
password: 'secret',
|
|
},
|
|
settings: {
|
|
ssl: true,
|
|
timeout: 30,
|
|
},
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Nested Data Credential',
|
|
type: 'databaseCredential',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
},
|
|
teamProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
|
|
// Verify file write was called
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
|
|
// Verify nested structure is preserved but values are sanitized
|
|
expect(exportedCredential.data).toMatchObject({
|
|
connection: {
|
|
host: '',
|
|
username: '',
|
|
password: '',
|
|
},
|
|
settings: {
|
|
// Numbers should be preserved according to TODO comment in service
|
|
timeout: 30,
|
|
},
|
|
});
|
|
|
|
// Verify numbers and booleans are preserved
|
|
expect((exportedCredential.data.connection as any).port).toBe(5432);
|
|
expect((exportedCredential.data.settings as any).ssl).toBe(true);
|
|
});
|
|
|
|
it('should export global credentials with isGlobal flag set to true', async () => {
|
|
// Arrange
|
|
const credentialData = {
|
|
apiKey: 'global-api-key',
|
|
apiSecret: 'global-secret',
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Global Test Credential',
|
|
type: 'globalCredentialType',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
isGlobal: true,
|
|
},
|
|
personalProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
expect(result.files).toHaveLength(1);
|
|
|
|
// Verify file write was called
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
|
|
// Verify isGlobal flag is exported
|
|
expect(exportedCredential).toMatchObject({
|
|
id: credential.id,
|
|
name: 'Global Test Credential',
|
|
type: 'globalCredentialType',
|
|
isGlobal: true,
|
|
data: {
|
|
apiKey: '',
|
|
apiSecret: '',
|
|
},
|
|
});
|
|
|
|
// Verify isGlobal is explicitly true
|
|
expect(exportedCredential.isGlobal).toBe(true);
|
|
});
|
|
|
|
it('should export non-global credentials with isGlobal flag set to false', async () => {
|
|
// Arrange
|
|
const credentialData = {
|
|
username: 'test-user',
|
|
password: 'test-password',
|
|
};
|
|
|
|
const credential = await createCredentials(
|
|
{
|
|
name: 'Non-Global Credential',
|
|
type: 'standardCredentialType',
|
|
data: Container.get(Cipher).encrypt(credentialData),
|
|
isGlobal: false,
|
|
},
|
|
teamProject,
|
|
);
|
|
|
|
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
|
|
|
// Act
|
|
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
|
|
|
// Assert
|
|
expect(result.count).toBe(1);
|
|
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
|
|
|
const exportedCredential = getWrittenCredentialData(credential.id);
|
|
|
|
// Verify isGlobal flag is false
|
|
expect(exportedCredential).toMatchObject({
|
|
id: credential.id,
|
|
name: 'Non-Global Credential',
|
|
type: 'standardCredentialType',
|
|
isGlobal: false,
|
|
});
|
|
|
|
// Verify isGlobal is explicitly false
|
|
expect(exportedCredential.isGlobal).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('exportTagsToWorkFolder', () => {
|
|
const tagsFilePath = () => path.join(exportDirectory, 'git', 'tags.json');
|
|
|
|
function getWrittenTagsData(): {
|
|
tags: Array<{ id: string; name: string }>;
|
|
mappings: Array<{ tagId: string; workflowId: string }>;
|
|
} {
|
|
const writeCall = mockFsWriteFile.mock.calls.find(
|
|
([filePath]) => filePath === tagsFilePath(),
|
|
);
|
|
if (!writeCall) {
|
|
throw new Error('No write call found for tags file');
|
|
}
|
|
return JSON.parse(writeCall[1] as string);
|
|
}
|
|
|
|
async function exportTags() {
|
|
return await exportService.exportTagsToWorkFolder(
|
|
await sourceControlContextFactory.createContext(testUser),
|
|
);
|
|
}
|
|
|
|
it('should export tags and mappings across multiple teams', async () => {
|
|
const team1 = await createTeamProject('Team 1', testUser);
|
|
const team2 = await createTeamProject('Team 2', testUser);
|
|
|
|
const [workflow1, workflow2, workflow3] = await Promise.all([
|
|
createWorkflowWithHistory({ id: 'wf1', name: 'Workflow 1' }, team1),
|
|
createWorkflowWithHistory({ id: 'wf2', name: 'Workflow 2' }, team1),
|
|
createWorkflowWithHistory({ id: 'wf3', name: 'Workflow 3' }, team2),
|
|
]);
|
|
|
|
const [tag1, tag2] = await Promise.all([
|
|
createTag({ id: 'tag1', name: 'Tag 1' }),
|
|
createTag({ id: 'tag2', name: 'Tag 2' }),
|
|
]);
|
|
|
|
await Promise.all([
|
|
assignTagToWorkflow(tag1, workflow1),
|
|
assignTagToWorkflow(tag2, workflow1),
|
|
assignTagToWorkflow(tag1, workflow2),
|
|
assignTagToWorkflow(tag2, workflow3),
|
|
]);
|
|
|
|
const result = await exportTags();
|
|
|
|
expect(result.count).toBe(2);
|
|
expect(result.files).toHaveLength(1);
|
|
|
|
const { tags, mappings } = getWrittenTagsData();
|
|
expect(tags).toEqual(
|
|
expect.arrayContaining([
|
|
{ id: 'tag1', name: 'Tag 1' },
|
|
{ id: 'tag2', name: 'Tag 2' },
|
|
]),
|
|
);
|
|
expect(mappings).toEqual(
|
|
expect.arrayContaining([
|
|
{ tagId: 'tag1', workflowId: 'wf1' },
|
|
{ tagId: 'tag2', workflowId: 'wf1' },
|
|
{ tagId: 'tag1', workflowId: 'wf2' },
|
|
{ tagId: 'tag2', workflowId: 'wf3' },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should export empty tags file when no tags exist', async () => {
|
|
const result = await exportTags();
|
|
|
|
expect(result.count).toBe(0);
|
|
expect(result.files).toHaveLength(1);
|
|
expect(mockFsWriteFile).toHaveBeenCalledWith(
|
|
tagsFilePath(),
|
|
JSON.stringify({ tags: [], mappings: [] }, null, 2),
|
|
);
|
|
});
|
|
|
|
it('should export tags without mappings when tags are unassigned', async () => {
|
|
await Promise.all([
|
|
createTag({ id: 'tag1', name: 'Tag 1' }),
|
|
createTag({ id: 'tag2', name: 'Tag 2' }),
|
|
]);
|
|
|
|
const result = await exportTags();
|
|
|
|
expect(result.count).toBe(2);
|
|
|
|
const { tags, mappings } = getWrittenTagsData();
|
|
expect(tags).toHaveLength(2);
|
|
expect(mappings).toHaveLength(0);
|
|
});
|
|
|
|
it('should export all mappings when multiple tags are on same workflow', async () => {
|
|
const team = await createTeamProject('Team', testUser);
|
|
const workflow = await createWorkflowWithHistory({ id: 'wf1', name: 'Workflow 1' }, team);
|
|
|
|
const tags = await Promise.all([
|
|
createTag({ id: 'tag1', name: 'Tag 1' }),
|
|
createTag({ id: 'tag2', name: 'Tag 2' }),
|
|
createTag({ id: 'tag3', name: 'Tag 3' }),
|
|
]);
|
|
|
|
await Promise.all(tags.map(async (tag) => await assignTagToWorkflow(tag, workflow)));
|
|
|
|
const result = await exportTags();
|
|
|
|
expect(result.count).toBe(3);
|
|
|
|
const { tags: exportedTags, mappings } = getWrittenTagsData();
|
|
expect(exportedTags).toHaveLength(3);
|
|
expect(mappings).toEqual(
|
|
expect.arrayContaining([
|
|
{ tagId: 'tag1', workflowId: 'wf1' },
|
|
{ tagId: 'tag2', workflowId: 'wf1' },
|
|
{ tagId: 'tag3', workflowId: 'wf1' },
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
});
|