import fsp from 'node:fs/promises'; import Container from 'typedi'; import { mock } from 'jest-mock-extended'; import * as utils from 'n8n-workflow'; import { Cipher } from 'n8n-core'; import { nanoid } from 'nanoid'; import type { InstanceSettings } from 'n8n-core'; import * as testDb from '../shared/testDb'; import { SourceControlImportService } from '@/environments/sourceControl/sourceControlImport.service.ee'; import { createMember, getGlobalOwner } from '../shared/db/users'; import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; import { mockInstance } from '../../shared/mocking'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; import type { ExportableCredential } from '@/environments/sourceControl/types/exportableCredential'; import { createTeamProject, getPersonalProject } from '../shared/db/projects'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { saveCredential } from '../shared/db/credentials'; import { randomCredentialPayload } from '../shared/random'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; describe('SourceControlImportService', () => { let service: SourceControlImportService; const cipher = mockInstance(Cipher); beforeAll(async () => { service = new SourceControlImportService( mock(), mock(), mock(), mock(), mock({ n8nFolder: '/some-path' }), ); await testDb.init(); }); afterEach(async () => { await testDb.truncate(['Credentials', 'SharedCredentials']); jest.restoreAllMocks(); }); afterAll(async () => { await testDb.terminate(); }); describe('importCredentialsFromWorkFolder()', () => { describe('if user email specified by `ownedBy` exists at target instance', () => { it('should assign credential ownership to original user', async () => { const [importingUser, member] = await Promise.all([getGlobalOwner(), createMember()]); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: member.email, // user at source instance owns credential }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const personalProject = await getPersonalProject(member); const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, projectId: personalProject.id, role: 'credential:owner', }); expect(sharing).toBeTruthy(); // same user at target instance owns credential }); }); describe('if user email specified by `ownedBy` is `null`', () => { it('should assign credential ownership to importing user', async () => { const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: null, }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const personalProject = await getPersonalProject(importingUser); const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, projectId: personalProject.id, role: 'credential:owner', }); expect(sharing).toBeTruthy(); // original user has no email, so importing user owns credential }); }); describe('if user email specified by `ownedBy` does not exist at target instance', () => { it('should assign credential ownership to importing user', async () => { const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: 'user@test.com', // user at source instance owns credential }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const personalProject = await getPersonalProject(importingUser); const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, projectId: personalProject.id, role: 'credential:owner', }); expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential }); }); }); describe('if owner specified by `ownedBy` does not exist at target instance', () => { it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => { const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: { type: 'personal', personalEmail: 'test@example.com', }, // user at source instance owns credential }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const personalProject = await getPersonalProject(importingUser); const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, projectId: personalProject.id, role: 'credential:owner', }); expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential }); it('should create a new team project if the credential was owned by a team project in the source instance', async () => { const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: { type: 'team', teamId: '1234-asdf', teamName: 'Marketing', }, // user at source instance owns credential }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); { const project = await Container.get(ProjectRepository).findOne({ where: [ { id: '1234-asdf', }, { name: 'Marketing' }, ], }); expect(project?.id).not.toBe('1234-asdf'); expect(project?.name).not.toBe('Marketing'); } await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const sharing = await Container.get(SharedCredentialsRepository).findOne({ where: { credentialsId: CREDENTIAL_ID, role: 'credential:owner', }, relations: { project: true }, }); expect(sharing?.project.id).toBe('1234-asdf'); expect(sharing?.project.name).toBe('Marketing'); expect(sharing?.project.type).toBe('team'); expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential }); }); describe('if owner specified by `ownedBy` does exist at target instance', () => { it('should use the existing team project if credential owning project is found', async () => { const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); const project = await createTeamProject('Sales'); const stub: ExportableCredential = { id: CREDENTIAL_ID, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: { type: 'team', teamId: project.id, teamName: 'Sales', }, }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); cipher.encrypt.mockReturnValue('some-encrypted-data'); await service.importCredentialsFromWorkFolder( [mock({ id: CREDENTIAL_ID })], importingUser.id, ); const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, projectId: project.id, role: 'credential:owner', }); expect(sharing).toBeTruthy(); }); it('should not change the owner if the credential is owned by somebody else on the target instance', async () => { cipher.encrypt.mockReturnValue('some-encrypted-data'); const importingUser = await getGlobalOwner(); fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); const targetProject = await createTeamProject('Marketing'); const credential = await saveCredential(randomCredentialPayload(), { project: targetProject, role: 'credential:owner', }); const sourceProjectId = nanoid(); const stub: ExportableCredential = { id: credential.id, name: 'My Credential', type: 'someCredentialType', data: {}, ownedBy: { type: 'team', teamId: sourceProjectId, teamName: 'Sales', }, }; jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); await service.importCredentialsFromWorkFolder( [mock({ id: credential.id })], importingUser.id, ); await expect( Container.get(SharedCredentialsRepository).findBy({ credentialsId: credential.id, }), ).resolves.toMatchObject([ { projectId: targetProject.id, role: 'credential:owner', }, ]); await expect( Container.get(CredentialsRepository).findBy({ id: credential.id, }), ).resolves.toMatchObject([ { name: stub.name, type: stub.type, data: 'some-encrypted-data', }, ]); }); }); });