diff --git a/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts new file mode 100644 index 0000000000..7746875f63 --- /dev/null +++ b/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts @@ -0,0 +1,85 @@ +import mock from 'jest-mock-extended/lib/Mock'; +import { SourceControlExportService } from '../sourceControlExport.service.ee'; +import type { SourceControlledFile } from '../types/sourceControlledFile'; +import { Cipher, type InstanceSettings } from 'n8n-core'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { mockInstance } from '@test/mocking'; + +import type { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import Container from 'typedi'; +import { ApplicationError, deepCopy } from 'n8n-workflow'; + +// https://github.com/jestjs/jest/issues/4715 +function deepSpyOn(object: O, methodName: M) { + const spy = jest.fn(); + // eslint-disable-next-line @typescript-eslint/ban-types + const originalMethod = object[methodName]; + + if (typeof originalMethod !== 'function') { + throw new ApplicationError(`${methodName.toString()} is not a function`, { level: 'warning' }); + } + + object[methodName] = function (...args: unknown[]) { + const clonedArgs = deepCopy(args); + spy(...clonedArgs); + return originalMethod.apply(this, args); + } as O[M]; + + return spy; +} + +describe('SourceControlExportService', () => { + const service = new SourceControlExportService( + mock(), + mock(), + mock(), + mock({ n8nFolder: '' }), + ); + + describe('exportCredentialsToWorkFolder', () => { + it('should export credentials to work folder', async () => { + /** + * Arrange + */ + // @ts-expect-error Private method + const replaceSpy = deepSpyOn(service, 'replaceCredentialData'); + + mockInstance(SharedCredentialsRepository).findByCredentialIds.mockResolvedValue([ + mock({ + credentials: mock({ + data: Container.get(Cipher).encrypt( + JSON.stringify({ + authUrl: 'test', + accessTokenUrl: 'test', + clientId: 'test', + clientSecret: 'test', + oauthTokenData: { + access_token: 'test', + token_type: 'test', + expires_in: 123, + refresh_token: 'test', + }, + }), + ), + }), + }), + ]); + + /** + * Act + */ + await service.exportCredentialsToWorkFolder([mock()]); + + /** + * Assert + */ + expect(replaceSpy).toHaveBeenCalledWith({ + authUrl: 'test', + accessTokenUrl: 'test', + clientId: 'test', + clientSecret: 'test', + }); + }); + }); +}); diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index e586357093..65886a6d6d 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -293,11 +293,18 @@ export class SourceControlExportService { }; } + /** + * Edge case: Do not export `oauthTokenData`, so that that the + * pulling instance reconnects instead of trying to use stubbed values. + */ + const credentialData = credentials.getData(); + const { oauthTokenData, ...rest } = credentialData; + const stub: ExportableCredential = { id, name, type, - data: this.replaceCredentialData(credentials.getData()), + data: this.replaceCredentialData(rest), ownedBy: owner, }; diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index d9a366b6f0..7ff77ca7f8 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -326,7 +326,12 @@ export class SourceControlImportService { if (existingCredential?.data) { newCredentialObject.data = existingCredential.data; } else { - newCredentialObject.setData(data); + /** + * Edge case: Do not import `oauthTokenData`, so that that the + * pulling instance reconnects instead of trying to use stubbed values. + */ + const { oauthTokenData, ...rest } = data; + newCredentialObject.setData(rest); } this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index cf677ba5bd..5426ce94ab 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1342,7 +1342,9 @@ export async function requestOAuth2( // if it's the first time using the credentials, get the access token and save it into the DB. if ( credentials.grantType === 'clientCredentials' && - (oauthTokenData === undefined || Object.keys(oauthTokenData).length === 0) + (oauthTokenData === undefined || + Object.keys(oauthTokenData).length === 0 || + oauthTokenData.access_token === '') // stub ) { const { data } = await oAuthClient.credentials.getToken(); // Find the credentials