fix(core): Credential export for personal projects (#19720)

This commit is contained in:
Andreas Fitzek
2025-09-18 18:08:00 +02:00
committed by GitHub
parent 64fa0ceea6
commit feebfd2f79
3 changed files with 460 additions and 1 deletions

View File

@@ -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,
},
});
});
});
});

View File

@@ -14,7 +14,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
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,

View File

@@ -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<typeof fsWriteFile>;
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);
});
});
});