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 type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential'; import { SourceControlContext } from '@/modules/source-control.ee/types/source-control-context'; 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 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; let mockFsReadFile: jest.MockedFunction; 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 service from container (this will use real dependencies) exportService = Container.get(SourceControlExportService); }); 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(new SourceControlContext(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' }, ]), ); }); }); });