diff --git a/packages/@n8n/db/src/repositories/__tests__/shared-credentials.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/shared-credentials.repository.test.ts new file mode 100644 index 0000000000..0a75426ce1 --- /dev/null +++ b/packages/@n8n/db/src/repositories/__tests__/shared-credentials.repository.test.ts @@ -0,0 +1,34 @@ +import { Container } from '@n8n/di'; +import { In } from '@n8n/typeorm'; + +import { SharedCredentials } from '../../entities'; +import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; +import { SharedCredentialsRepository } from '../shared-credentials.repository'; + +describe('SharedCredentialsRepository', () => { + const entityManager = mockEntityManager(SharedCredentials); + const sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('findByCredentialIds', () => { + it('should return shared credentials with project relations including role', async () => { + const credentialIds = ['cred1', 'cred2']; + const role = 'credential:owner'; + + entityManager.find.mockResolvedValueOnce([]); + + await sharedCredentialsRepository.findByCredentialIds(credentialIds, role); + + expect(entityManager.find).toHaveBeenCalledWith(SharedCredentials, { + relations: { credentials: true, project: { projectRelations: { user: true, role: true } } }, + where: { + credentialsId: In(credentialIds), + role, + }, + }); + }); + }); +}); diff --git a/packages/@n8n/db/src/repositories/shared-credentials.repository.ts b/packages/@n8n/db/src/repositories/shared-credentials.repository.ts index 9dc1dd88f1..f31929fd04 100644 --- a/packages/@n8n/db/src/repositories/shared-credentials.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-credentials.repository.ts @@ -14,7 +14,7 @@ export class SharedCredentialsRepository extends Repository { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { return await this.find({ - relations: { credentials: true, project: { projectRelations: { user: true } } }, + relations: { credentials: true, project: { projectRelations: { user: true, role: true } } }, where: { credentialsId: In(credentialIds), role, diff --git a/packages/cli/test/integration/environments/source-control-export.service.test.ts b/packages/cli/test/integration/environments/source-control-export.service.test.ts new file mode 100644 index 0000000000..0b4ed2d46a --- /dev/null +++ b/packages/cli/test/integration/environments/source-control-export.service.test.ts @@ -0,0 +1,425 @@ +import type { SourceControlledFile } from '@n8n/api-types'; +import { createTeamProject, 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 { writeFile as fsWriteFile } from 'node:fs/promises'; +import path from 'node:path'; +import { v4 as uuid } from 'uuid'; + +import { SourceControlExportService } from '@/environments.ee/source-control/source-control-export.service.ee'; +import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; + +import { createCredentials } from '../shared/db/credentials'; +import { createUser } from '../shared/db/users'; + +// Mock file system operations +jest.mock('node:fs/promises'); +jest.mock('@/environments.ee/source-control/source-control-helper.ee', () => ({ + ...jest.requireActual('@/environments.ee/source-control/source-control-helper.ee'), + sourceControlFoldersExistCheck: jest.fn().mockResolvedValue(true), +})); + +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; + + 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(); + }); + + afterEach(async () => { + // Clear test data between tests + await testDb.truncate(['SharedCredentials', 'CredentialsEntity']); + // 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); + }); + }); +});