mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
fix(Salesforce Node): Reuse JWT session token across requests (#32325)
This commit is contained in:
committed by
GitHub
parent
2c3c67f3da
commit
0e4d2c3bdb
@@ -18,6 +18,7 @@ import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialType,
|
||||
IHttpRequestHelper,
|
||||
IHttpRequestOptions,
|
||||
INode,
|
||||
INodeProperties,
|
||||
@@ -26,6 +27,14 @@ import type {
|
||||
IWorkflowExecuteAdditionalData,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy, Workflow } from 'n8n-workflow';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { SalesforceJwtApi } from 'n8n-nodes-base/credentials/SalesforceJwtApi.credentials';
|
||||
|
||||
// The credential module resolves to nodes-base source, which uses a package-internal
|
||||
// path alias not mapped by cli's jest config.
|
||||
jest.mock('@utils/utilities', () => ({ formatPrivateKey: (key: string) => key }), {
|
||||
virtual: true,
|
||||
});
|
||||
|
||||
import { CredentialTypes } from '@/credential-types';
|
||||
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
|
||||
@@ -1555,4 +1564,138 @@ describe('CredentialsHelper', () => {
|
||||
expect(resultB.apiKey).toBe('key_account_B_UPDATED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preAuthentication token caching', () => {
|
||||
// Proves the framework performs the JWT login only when the cached token is
|
||||
// missing or expired, so chained Salesforce actions reuse one session instead
|
||||
// of authenticating on every request. The login is the credential's
|
||||
// `preAuthentication` hook, which we observe through a counting `httpRequest`.
|
||||
const { privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const salesforceJwt = new SalesforceJwtApi();
|
||||
|
||||
const node: INode = {
|
||||
id: 'uuid-sf',
|
||||
name: 'Salesforce',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: { salesforceJwtApi: { id: 'sf-cred', name: 'Salesforce JWT' } },
|
||||
};
|
||||
|
||||
let httpRequest: jest.Mock;
|
||||
let helpers: IHttpRequestHelper;
|
||||
let updateSpy: jest.SpyInstance;
|
||||
let credentials: ICredentialDataDecryptedObject;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockNodesAndCredentials.getCredential
|
||||
.calledWith('salesforceJwtApi')
|
||||
.mockReturnValue({ type: salesforceJwt, sourcePath: '' });
|
||||
|
||||
let logins = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
httpRequest = jest.fn().mockImplementation(async () => ({
|
||||
access_token: `TOKEN_${++logins}`,
|
||||
instance_url: 'https://acme.my.salesforce.com',
|
||||
}));
|
||||
helpers = { helpers: { httpRequest } } as unknown as IHttpRequestHelper;
|
||||
|
||||
// Stub persistence so the test does not touch the DB. The returned token is
|
||||
// what the request helper merges back into the in-memory credentials for the
|
||||
// next request (see httpRequestWithAuthentication).
|
||||
updateSpy = jest.spyOn(credentialsHelper, 'updateCredentials').mockResolvedValue();
|
||||
|
||||
credentials = {
|
||||
accessToken: '',
|
||||
instanceUrl: '',
|
||||
clientId: 'connected-app-client-id',
|
||||
username: 'user@example.com',
|
||||
privateKey,
|
||||
environment: 'production',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => updateSpy.mockRestore());
|
||||
|
||||
test('logs in once and reuses the cached token across requests', async () => {
|
||||
// Request 1: no cached token → exactly one login.
|
||||
const first = await credentialsHelper.preAuthentication(
|
||||
helpers,
|
||||
credentials,
|
||||
'salesforceJwtApi',
|
||||
node,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: 'https://login.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
accessToken: 'TOKEN_1',
|
||||
instanceUrl: 'https://acme.my.salesforce.com',
|
||||
});
|
||||
// The token would be persisted so the next request reads it back.
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// preAuthentication merges the token into the in-memory credentials, exactly
|
||||
// as the request helper does before the next request.
|
||||
Object.assign(credentials, first);
|
||||
|
||||
// Requests 2 and 3 already have a token → no further logins.
|
||||
const second = await credentialsHelper.preAuthentication(
|
||||
helpers,
|
||||
credentials,
|
||||
'salesforceJwtApi',
|
||||
node,
|
||||
false,
|
||||
);
|
||||
const third = await credentialsHelper.preAuthentication(
|
||||
helpers,
|
||||
credentials,
|
||||
'salesforceJwtApi',
|
||||
node,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(second).toBeUndefined();
|
||||
expect(third).toBeUndefined();
|
||||
// Still only the single login from request 1 across all three requests.
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('re-authenticates when the cached token is reported expired (e.g. after a 401)', async () => {
|
||||
await credentialsHelper.preAuthentication(
|
||||
helpers,
|
||||
credentials,
|
||||
'salesforceJwtApi',
|
||||
node,
|
||||
false,
|
||||
);
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(credentials.accessToken).toBe('TOKEN_1');
|
||||
|
||||
// credentialsExpired = true mirrors the request helper's retry after a 401.
|
||||
const refreshed = await credentialsHelper.preAuthentication(
|
||||
helpers,
|
||||
credentials,
|
||||
'salesforceJwtApi',
|
||||
node,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(httpRequest).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed).toMatchObject({ accessToken: 'TOKEN_2' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
@@ -8,9 +6,11 @@ import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
IHttpRequestHelper,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
export class SalesforceJwtApi implements ICredentialType {
|
||||
name = 'salesforceJwtApi';
|
||||
@@ -20,6 +20,21 @@ export class SalesforceJwtApi implements ICredentialType {
|
||||
documentationUrl = 'salesforce';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Access Token',
|
||||
name: 'accessToken',
|
||||
type: 'hidden',
|
||||
typeOptions: {
|
||||
expirable: true,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Instance URL',
|
||||
name: 'instanceUrl',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Environment Type',
|
||||
name: 'environment',
|
||||
@@ -75,10 +90,11 @@ export class SalesforceJwtApi implements ICredentialType {
|
||||
},
|
||||
];
|
||||
|
||||
async authenticate(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
// Only called when "accessToken" (the expirable property) is empty or expired.
|
||||
// Exchanges the signed JWT for an access token once and caches it (together with
|
||||
// the instance URL) so chained Salesforce actions reuse the same session instead
|
||||
// of logging in on every request.
|
||||
async preAuthentication(this: IHttpRequestHelper, credentials: ICredentialDataDecryptedObject) {
|
||||
const now = moment().unix();
|
||||
const authUrl = resolveAuthUrl(credentials);
|
||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||
@@ -98,28 +114,44 @@ export class SalesforceJwtApi implements ICredentialType {
|
||||
},
|
||||
);
|
||||
|
||||
const axiosRequestConfig: AxiosRequestConfig = {
|
||||
const response = (await this.helpers.httpRequest({
|
||||
method: 'POST',
|
||||
url: `${authUrl}/services/oauth2/token`,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
method: 'POST',
|
||||
data: new URLSearchParams({
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: signature,
|
||||
}).toString(),
|
||||
url: `${authUrl}/services/oauth2/token`,
|
||||
responseType: 'json',
|
||||
};
|
||||
const result = await axios(axiosRequestConfig);
|
||||
const { access_token } = result.data as { access_token: string };
|
||||
})) as { access_token?: string; instance_url?: string };
|
||||
|
||||
return {
|
||||
...requestOptions,
|
||||
headers: {
|
||||
...requestOptions.headers,
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
if (!response.access_token || !response.instance_url) {
|
||||
throw new OperationalError(
|
||||
'Salesforce JWT authentication did not return an access token and instance URL',
|
||||
);
|
||||
}
|
||||
|
||||
return { accessToken: response.access_token, instanceUrl: response.instance_url };
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
Authorization: `Bearer ${credentials.accessToken as string}`,
|
||||
};
|
||||
|
||||
// Node requests pass a relative URL and rely on the cached instance URL as base.
|
||||
// The credential test supplies its own baseURL (the login/My Domain URL), which
|
||||
// must be left untouched.
|
||||
if (!requestOptions.baseURL && credentials.instanceUrl) {
|
||||
requestOptions.baseURL = credentials.instanceUrl as string;
|
||||
}
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IHttpRequestHelper,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { SalesforceJwtApi, resolveAuthUrl } from '../SalesforceJwtApi.credentials';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('axios');
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { sign: vi.fn() },
|
||||
}));
|
||||
@@ -15,8 +17,9 @@ vi.mock('@utils/utilities', () => ({
|
||||
|
||||
describe('SalesforceJwtApi Credential', () => {
|
||||
const credential = new SalesforceJwtApi();
|
||||
const mockedAxios = axios as unknown as Mock;
|
||||
const mockedSign = jwt.sign as unknown as Mock;
|
||||
const mockHttpRequest = vi.fn();
|
||||
const helpers = { helpers: { httpRequest: mockHttpRequest } } as unknown as IHttpRequestHelper;
|
||||
|
||||
const baseCredentials = {
|
||||
clientId: 'connected-app-client-id',
|
||||
@@ -24,15 +27,12 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
url: 'https://login.salesforce.com/services/data/v59.0',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedAxios.mockReset();
|
||||
mockedAxios.mockResolvedValue({ data: { access_token: 'abc123' } });
|
||||
mockHttpRequest.mockReset();
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
access_token: 'abc123',
|
||||
instance_url: 'https://acme.my.salesforce.com',
|
||||
});
|
||||
mockedSign.mockReset();
|
||||
mockedSign.mockReturnValue('signed-jwt');
|
||||
});
|
||||
@@ -47,6 +47,19 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
expect(credential.test.request.url).toBe('/services/oauth2/userinfo');
|
||||
});
|
||||
|
||||
it('caches the access token in an expirable hidden field', () => {
|
||||
const accessToken = credential.properties.find(
|
||||
(property: INodeProperties) => property.name === 'accessToken',
|
||||
);
|
||||
expect(accessToken?.type).toBe('hidden');
|
||||
expect(accessToken?.typeOptions?.expirable).toBe(true);
|
||||
|
||||
const instanceUrl = credential.properties.find(
|
||||
(property: INodeProperties) => property.name === 'instanceUrl',
|
||||
);
|
||||
expect(instanceUrl?.type).toBe('hidden');
|
||||
});
|
||||
|
||||
describe('resolveAuthUrl', () => {
|
||||
it('defaults to login.salesforce.com for production when My Domain URL is empty', () => {
|
||||
expect(
|
||||
@@ -97,37 +110,39 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
describe('preAuthentication', () => {
|
||||
const callPreAuthentication = async (credentials: ICredentialDataDecryptedObject) =>
|
||||
await credential.preAuthentication.call(helpers, credentials);
|
||||
|
||||
it('signs the JWT with the sandbox default audience when no My Domain URL is set', async () => {
|
||||
await credential.authenticate(
|
||||
{ ...baseCredentials, environment: 'sandbox', myDomainUrl: '' },
|
||||
requestOptions,
|
||||
);
|
||||
await callPreAuthentication({ ...baseCredentials, environment: 'sandbox', myDomainUrl: '' });
|
||||
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ aud: 'https://test.salesforce.com' }),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockedAxios).toHaveBeenCalledWith(
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: 'https://test.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('signs the JWT with the production default audience when no My Domain URL is set', async () => {
|
||||
await credential.authenticate(
|
||||
{ ...baseCredentials, environment: 'production', myDomainUrl: '' },
|
||||
requestOptions,
|
||||
);
|
||||
await callPreAuthentication({
|
||||
...baseCredentials,
|
||||
environment: 'production',
|
||||
myDomainUrl: '',
|
||||
});
|
||||
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ aud: 'https://login.salesforce.com' }),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockedAxios).toHaveBeenCalledWith(
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://login.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
@@ -135,21 +150,18 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
});
|
||||
|
||||
it("signs the JWT with the Spring '26 sandbox My Domain URL as audience (issue #28990)", async () => {
|
||||
await credential.authenticate(
|
||||
{
|
||||
...baseCredentials,
|
||||
environment: 'sandbox',
|
||||
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
await callPreAuthentication({
|
||||
...baseCredentials,
|
||||
environment: 'sandbox',
|
||||
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
|
||||
});
|
||||
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockedAxios).toHaveBeenCalledWith(
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
@@ -157,21 +169,18 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
});
|
||||
|
||||
it('signs the JWT with a production My Domain URL as audience', async () => {
|
||||
await credential.authenticate(
|
||||
{
|
||||
...baseCredentials,
|
||||
environment: 'production',
|
||||
myDomainUrl: 'https://acme.my.salesforce.com',
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
await callPreAuthentication({
|
||||
...baseCredentials,
|
||||
environment: 'production',
|
||||
myDomainUrl: 'https://acme.my.salesforce.com',
|
||||
});
|
||||
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ aud: 'https://acme.my.salesforce.com' }),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockedAxios).toHaveBeenCalledWith(
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://acme.my.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
@@ -179,34 +188,90 @@ describe('SalesforceJwtApi Credential', () => {
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash in the My Domain URL before signing', async () => {
|
||||
await credential.authenticate(
|
||||
{
|
||||
...baseCredentials,
|
||||
environment: 'sandbox',
|
||||
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/',
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
await callPreAuthentication({
|
||||
...baseCredentials,
|
||||
environment: 'sandbox',
|
||||
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/',
|
||||
});
|
||||
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockedAxios).toHaveBeenCalledWith(
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the returned access token to the outgoing request', async () => {
|
||||
const result = await credential.authenticate(
|
||||
{ ...baseCredentials, environment: 'production', myDomainUrl: '' },
|
||||
requestOptions,
|
||||
);
|
||||
it('returns the access token and instance URL to cache', async () => {
|
||||
mockHttpRequest.mockResolvedValueOnce({
|
||||
access_token: 'cached-token',
|
||||
instance_url: 'https://acme.my.salesforce.com',
|
||||
});
|
||||
|
||||
expect(result.headers?.Authorization).toBe('Bearer abc123');
|
||||
const result = await callPreAuthentication({
|
||||
...baseCredentials,
|
||||
environment: 'production',
|
||||
myDomainUrl: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'cached-token',
|
||||
instanceUrl: 'https://acme.my.salesforce.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the token response is missing the access token or instance URL', async () => {
|
||||
mockHttpRequest.mockResolvedValueOnce({ access_token: 'cached-token' });
|
||||
|
||||
await expect(
|
||||
callPreAuthentication({ ...baseCredentials, environment: 'production', myDomainUrl: '' }),
|
||||
).rejects.toThrow('Salesforce JWT authentication did not return an access token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
const credentials = {
|
||||
...baseCredentials,
|
||||
accessToken: 'cached-token',
|
||||
instanceUrl: 'https://acme.my.salesforce.com',
|
||||
};
|
||||
|
||||
it('attaches the cached bearer token and resolves the instance URL for node requests', async () => {
|
||||
const result = await credential.authenticate(credentials, {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
url: '/services/data/v59.0/sobjects/Account/describe',
|
||||
});
|
||||
|
||||
expect(result.headers?.Authorization).toBe('Bearer cached-token');
|
||||
expect(result.baseURL).toBe('https://acme.my.salesforce.com');
|
||||
});
|
||||
|
||||
it('does not overwrite a baseURL provided by the caller (credential test)', async () => {
|
||||
const result = await credential.authenticate(credentials, {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
baseURL: 'https://login.salesforce.com',
|
||||
url: '/services/oauth2/userinfo',
|
||||
});
|
||||
|
||||
expect(result.headers?.Authorization).toBe('Bearer cached-token');
|
||||
expect(result.baseURL).toBe('https://login.salesforce.com');
|
||||
});
|
||||
|
||||
it('does not perform a token exchange', async () => {
|
||||
await credential.authenticate(credentials, {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
url: '/services/data/v59.0/sobjects/Account/describe',
|
||||
});
|
||||
|
||||
expect(mockHttpRequest).not.toHaveBeenCalled();
|
||||
expect(mockedSign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import moment from 'moment-timezone';
|
||||
import { DateTime } from 'luxon';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
INodePropertyOptions,
|
||||
JsonObject,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
IRequestOptions,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { resolveAuthUrl } from '../../credentials/SalesforceJwtApi.credentials';
|
||||
|
||||
type SalesforceApiError = {
|
||||
errorCode?: string;
|
||||
fields?: string[];
|
||||
@@ -26,6 +22,8 @@ type SalesforceApiErrorResponse = {
|
||||
error?: SalesforceApiError[];
|
||||
};
|
||||
|
||||
const SALESFORCE_API_VERSION = 'v59.0';
|
||||
|
||||
function getOptions(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
@@ -42,7 +40,7 @@ function getOptions(
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri: `${instanceUrl}/services/data/v59.0${endpoint}`,
|
||||
uri: `${instanceUrl}/services/data/${SALESFORCE_API_VERSION}${endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
@@ -53,45 +51,6 @@ function getOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
async function getAccessToken(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
): Promise<IDataObject> {
|
||||
const now = moment().unix();
|
||||
const authUrl = resolveAuthUrl(credentials);
|
||||
|
||||
const signature = jwt.sign(
|
||||
{
|
||||
iss: credentials.clientId as string,
|
||||
sub: credentials.username as string,
|
||||
aud: authUrl,
|
||||
exp: now + 3 * 60,
|
||||
},
|
||||
credentials.privateKey as string,
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
header: {
|
||||
alg: 'RS256',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
method: 'POST',
|
||||
form: {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: signature,
|
||||
},
|
||||
uri: `${authUrl}/services/oauth2/token`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
return await this.helpers.request(options);
|
||||
}
|
||||
|
||||
export async function salesforceApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
@@ -106,24 +65,30 @@ export async function salesforceApiRequest(
|
||||
try {
|
||||
if (authenticationMethod === 'jwt') {
|
||||
// https://help.salesforce.com/articleView?id=remoteaccess_oauth_jwt_flow.htm&type=5
|
||||
// The access token and instance URL are cached on the credential and reused
|
||||
// across requests; the credential's authenticate hook attaches the Bearer
|
||||
// header and resolves the relative URL against the cached instance URL.
|
||||
const credentialsType = 'salesforceJwtApi';
|
||||
const credentials = await this.getCredentials(credentialsType);
|
||||
const response = await getAccessToken.call(this, credentials);
|
||||
const { instance_url, access_token } = response;
|
||||
const options = getOptions.call(
|
||||
this,
|
||||
const options: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
uri || endpoint,
|
||||
body,
|
||||
qs,
|
||||
instance_url as string,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.uri}`,
|
||||
);
|
||||
options.headers!.Authorization = `Bearer ${access_token}`;
|
||||
url: `/services/data/${SALESFORCE_API_VERSION}${uri || endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (!Object.keys(options.body as IDataObject).length) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
Object.assign(options, option);
|
||||
return await this.helpers.request(options);
|
||||
this.logger.debug(
|
||||
`Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.url}`,
|
||||
);
|
||||
return await this.helpers.httpRequestWithAuthentication.call(this, credentialsType, options);
|
||||
} else {
|
||||
// https://help.salesforce.com/articleView?id=remoteaccess_oauth_web_server_flow.htm&type=5
|
||||
const credentialsType = 'salesforceOAuth2Api';
|
||||
@@ -146,9 +111,19 @@ export async function salesforceApiRequest(
|
||||
return await this.helpers.requestOAuth2.call(this, credentialsType, options);
|
||||
}
|
||||
} catch (error) {
|
||||
const salesforceError = error as SalesforceApiErrorResponse;
|
||||
const salesforceError = error as SalesforceApiErrorResponse & {
|
||||
cause?: { response?: { data?: unknown } };
|
||||
};
|
||||
|
||||
const sfErrors = Array.isArray(salesforceError.error) ? salesforceError.error : [];
|
||||
// Salesforce REST errors arrive as an array on `error.error` (OAuth2 path via the
|
||||
// legacy request helper) or on the wrapped Axios error's response data under
|
||||
// `error.cause` (JWT path via the authenticated request helper).
|
||||
const responseData = salesforceError.cause?.response?.data;
|
||||
const sfErrors: SalesforceApiError[] = Array.isArray(salesforceError.error)
|
||||
? salesforceError.error
|
||||
: Array.isArray(responseData)
|
||||
? (responseData as SalesforceApiError[])
|
||||
: [];
|
||||
|
||||
const allFields = sfErrors.flatMap((e) => e.fields ?? []).join(', ') || null;
|
||||
|
||||
|
||||
@@ -655,15 +655,26 @@ describe('Salesforce -> GenericFunctions', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockRequest = vi.fn();
|
||||
mockExecuteFunctions.helpers.request = mockRequest;
|
||||
// The node now delegates JWT auth to the credential via the authenticated
|
||||
// request helper, which caches and reuses the token across requests.
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication = mockRequest;
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mocks
|
||||
(mockJwt.sign as Mock).mockReturnValue('mock-jwt-signature');
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'authentication') return 'jwt';
|
||||
return undefined;
|
||||
});
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -671,186 +682,30 @@ describe('Salesforce -> GenericFunctions', () => {
|
||||
});
|
||||
|
||||
describe('JWT Authentication Flow', () => {
|
||||
it('should authenticate using JWT with production environment', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
it('routes the request through the credential without signing or exchanging tokens', async () => {
|
||||
mockRequest.mockResolvedValue({ records: [] });
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
// Verify JWT signature generation
|
||||
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
|
||||
{
|
||||
iss: 'test-client-id',
|
||||
sub: 'test@example.com',
|
||||
aud: 'https://login.salesforce.com',
|
||||
exp: 1640995200 + 3 * 60, // Current timestamp + 3 minutes
|
||||
},
|
||||
'mock-private-key',
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
header: {
|
||||
alg: 'RS256',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Verify token exchange request
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
method: 'POST',
|
||||
form: {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: 'mock-jwt-signature',
|
||||
},
|
||||
uri: 'https://login.salesforce.com/services/oauth2/token',
|
||||
json: true,
|
||||
});
|
||||
|
||||
// Verify API request with bearer token
|
||||
const expectedApiOptions = {
|
||||
// The node delegates to the authenticated request helper with a RELATIVE url;
|
||||
// the credential attaches the cached Bearer token and resolves the instance URL.
|
||||
expect(mockRequest).toHaveBeenCalledWith('salesforceJwtApi', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
},
|
||||
method: 'GET',
|
||||
qs: {},
|
||||
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
|
||||
json: true,
|
||||
};
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
|
||||
expect(mockExecuteFunctions.logger.debug).toHaveBeenCalledWith(
|
||||
'Authentication for "Salesforce" node is using "jwt". Invoking URI https://test.salesforce.com/services/data/v59.0/test-endpoint',
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate using JWT with sandbox environment', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'sandbox-client-id',
|
||||
username: 'sandbox@example.com',
|
||||
privateKey: 'sandbox-private-key',
|
||||
environment: 'sandbox',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'sandbox-access-token',
|
||||
instance_url: 'https://test.my.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await salesforceApiRequest.call(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
'/sandbox-endpoint',
|
||||
{ data: 'test' },
|
||||
{ param: 'value' },
|
||||
);
|
||||
|
||||
// Verify JWT uses sandbox URL
|
||||
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
|
||||
{
|
||||
iss: 'sandbox-client-id',
|
||||
sub: 'sandbox@example.com',
|
||||
aud: 'https://test.salesforce.com',
|
||||
exp: 1640995200 + 3 * 60,
|
||||
},
|
||||
'sandbox-private-key',
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
header: {
|
||||
alg: 'RS256',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Verify token exchange request uses sandbox URL
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
method: 'POST',
|
||||
form: {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: 'mock-jwt-signature',
|
||||
},
|
||||
uri: 'https://test.salesforce.com/services/oauth2/token',
|
||||
url: '/services/data/v59.0/test-endpoint',
|
||||
json: true,
|
||||
});
|
||||
|
||||
// The token exchange now lives in the credential, not the node.
|
||||
expect(mockJwt.sign as Mock).not.toHaveBeenCalled();
|
||||
expect(mockExecuteFunctions.getCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use the My Domain URL as JWT audience and token endpoint when set (Spring '26)", async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'sandbox',
|
||||
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'my-domain-access-token',
|
||||
instance_url: 'https://acme--sandbox.sandbox.my.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
aud: 'https://acme--sandbox.sandbox.my.salesforce.com',
|
||||
}),
|
||||
'mock-private-key',
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uri: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle JWT token exchange with body and query parameters', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
it('forwards body and query parameters', async () => {
|
||||
mockRequest.mockResolvedValue({});
|
||||
|
||||
const testBody = { name: 'Test Account', type: 'Customer' };
|
||||
const testQs = { fields: 'Id,Name', limit: '10' };
|
||||
@@ -863,39 +718,20 @@ describe('Salesforce -> GenericFunctions', () => {
|
||||
testQs,
|
||||
);
|
||||
|
||||
// Verify API request includes body and query parameters
|
||||
const expectedApiOptions = {
|
||||
expect(mockRequest).toHaveBeenCalledWith('salesforceJwtApi', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
},
|
||||
method: 'POST',
|
||||
body: testBody,
|
||||
qs: testQs,
|
||||
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
|
||||
url: '/services/data/v59.0/test-endpoint',
|
||||
json: true,
|
||||
};
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom URI parameter', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
it('uses the custom URI when provided', async () => {
|
||||
mockRequest.mockResolvedValue({});
|
||||
|
||||
await salesforceApiRequest.call(
|
||||
mockExecuteFunctions,
|
||||
@@ -906,42 +742,20 @@ describe('Salesforce -> GenericFunctions', () => {
|
||||
'/custom-uri',
|
||||
);
|
||||
|
||||
// Verify custom URI is used instead of endpoint
|
||||
const expectedApiOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
},
|
||||
method: 'GET',
|
||||
qs: {},
|
||||
uri: 'https://test.salesforce.com/services/data/v59.0/custom-uri',
|
||||
json: true,
|
||||
};
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
'salesforceJwtApi',
|
||||
expect.objectContaining({
|
||||
url: '/services/data/v59.0/custom-uri',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge additional options', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
it('merges additional options', async () => {
|
||||
mockRequest.mockResolvedValue({});
|
||||
|
||||
const additionalOptions = {
|
||||
timeout: 30000,
|
||||
resolveWithFullResponse: true,
|
||||
returnFullResponse: true,
|
||||
};
|
||||
|
||||
await salesforceApiRequest.call(
|
||||
@@ -954,238 +768,74 @@ describe('Salesforce -> GenericFunctions', () => {
|
||||
additionalOptions,
|
||||
);
|
||||
|
||||
// Verify additional options are merged
|
||||
const expectedApiOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
},
|
||||
method: 'GET',
|
||||
qs: {},
|
||||
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
|
||||
json: true,
|
||||
timeout: 30000,
|
||||
resolveWithFullResponse: true,
|
||||
};
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
'salesforceJwtApi',
|
||||
expect.objectContaining({
|
||||
url: '/services/data/v59.0/test-endpoint',
|
||||
timeout: 30000,
|
||||
returnFullResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
|
||||
it('omits an empty body', async () => {
|
||||
mockRequest.mockResolvedValue({});
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
expect(mockRequest.mock.calls[0][1]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('logs the relative URL being invoked', async () => {
|
||||
mockRequest.mockResolvedValue({});
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
expect(mockExecuteFunctions.logger.debug).toHaveBeenCalledWith(
|
||||
'Authentication for "Salesforce" node is using "jwt". Invoking URI /services/data/v59.0/test-endpoint',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWT Authentication Error Handling', () => {
|
||||
it('should handle credential retrieval errors', async () => {
|
||||
const credentialError = new Error('Failed to get credentials');
|
||||
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('salesforceJwtApi');
|
||||
});
|
||||
|
||||
it('should handle JWT token exchange errors', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const tokenError = new Error('Invalid JWT signature');
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockRejectedValue(tokenError);
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
it('wraps request errors in NodeApiError', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('API rate limit exceeded'));
|
||||
|
||||
await expect(
|
||||
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
|
||||
it('should handle API request errors after successful authentication', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
const apiError = new Error('API rate limit exceeded');
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(mockResponse) // First call succeeds (token exchange)
|
||||
.mockRejectedValueOnce(apiError); // Second call fails (API request)
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
// Verify that token exchange succeeded but API request failed
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle missing instance_url in token response', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'mock-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'mock-access-token',
|
||||
// Missing instance_url
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.salesforce',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Should not throw error but handle gracefully
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
// Verify API call was made with undefined instance_url
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWT Signature Generation', () => {
|
||||
it('should generate correct JWT payload for production environment', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'prod-client-id',
|
||||
username: 'prod@example.com',
|
||||
privateKey: 'prod-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'prod-access-token',
|
||||
instance_url: 'https://prod.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
|
||||
{
|
||||
iss: 'prod-client-id',
|
||||
sub: 'prod@example.com',
|
||||
aud: 'https://login.salesforce.com',
|
||||
exp: 1640995200 + 180, // 3 minutes = 180 seconds
|
||||
},
|
||||
'prod-private-key',
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
header: {
|
||||
alg: 'RS256',
|
||||
it('preserves the Salesforce error code and fields from the wrapped error', async () => {
|
||||
// The authenticated helper wraps transport errors and keeps the original
|
||||
// Axios error (with the Salesforce error body) under `cause`.
|
||||
mockRequest.mockRejectedValue({
|
||||
cause: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
fields: ['AnnualRevenue'],
|
||||
message: 'Annual Revenue cannot be negative.',
|
||||
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
|
||||
},
|
||||
{
|
||||
fields: ['Phone'],
|
||||
message: 'Phone number is invalid.',
|
||||
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate correct JWT payload for sandbox environment', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'sandbox-client-id',
|
||||
username: 'sandbox@example.com',
|
||||
privateKey: 'sandbox-private-key',
|
||||
environment: 'sandbox',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'sandbox-access-token',
|
||||
instance_url: 'https://sandbox.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
|
||||
{
|
||||
iss: 'sandbox-client-id',
|
||||
sub: 'sandbox@example.com',
|
||||
aud: 'https://test.salesforce.com', // Sandbox uses test.salesforce.com
|
||||
exp: 1640995200 + 180,
|
||||
await expect(
|
||||
salesforceApiRequest.call(mockExecuteFunctions, 'POST', '/sobjects/Lead', {}),
|
||||
).rejects.toMatchObject({
|
||||
context: {
|
||||
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
|
||||
fields: 'AnnualRevenue, Phone',
|
||||
},
|
||||
'sandbox-private-key',
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
header: {
|
||||
alg: 'RS256',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use RS256 algorithm and proper header configuration', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
username: 'test@example.com',
|
||||
privateKey: 'test-private-key',
|
||||
environment: 'production',
|
||||
};
|
||||
const mockResponse = {
|
||||
access_token: 'test-access-token',
|
||||
instance_url: 'https://test.salesforce.com',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
|
||||
mockRequest.mockResolvedValue(mockResponse);
|
||||
mockExecuteFunctions.logger = {
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
|
||||
|
||||
const jwtOptions = (mockJwt.sign as Mock).mock.calls[0][2];
|
||||
expect(jwtOptions.algorithm).toBe('RS256');
|
||||
expect(jwtOptions.header?.alg).toBe('RS256');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -172,4 +172,35 @@ describe('Salesforce Node', () => {
|
||||
workflowFiles: ['opportunities.workflow.json'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('jwt authentication', () => {
|
||||
// The cached access token and instance URL are stored on the credential; the node
|
||||
// sends a relative URL and the credential resolves it against the instance URL and
|
||||
// attaches the cached Bearer token instead of logging in per request.
|
||||
const jwtCredentials = {
|
||||
salesforceJwtApi: {
|
||||
accessToken: 'CACHEDTOKEN',
|
||||
instanceUrl: 'https://salesforce.instance',
|
||||
clientId: 'connected-app-client-id',
|
||||
username: 'user@example.com',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----',
|
||||
environment: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock('https://salesforce.instance/services/data/v59.0')
|
||||
.matchHeader('authorization', 'Bearer CACHEDTOKEN')
|
||||
.get('/query')
|
||||
.query({
|
||||
q: 'SELECT id, name, type FROM Account',
|
||||
})
|
||||
.reply(200, { records: accounts });
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials: jwtCredentials,
|
||||
workflowFiles: ['search-jwt.workflow.json'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "search-jwt.workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-240, 100],
|
||||
"id": "e3a94904-ddfa-4fe8-bac2-e2c796c677a3",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "jwt",
|
||||
"resource": "search",
|
||||
"query": "SELECT id, name, type FROM Account"
|
||||
},
|
||||
"type": "n8n-nodes-base.salesforce",
|
||||
"typeVersion": 1,
|
||||
"position": [-20, 100],
|
||||
"id": "0fcf65ac-c9a3-48b5-9c39-599216208b62",
|
||||
"name": "Salesforce",
|
||||
"credentials": {
|
||||
"salesforceJwtApi": {
|
||||
"id": "jWtFg0tv79sQS6pc",
|
||||
"name": "Salesforce JWT account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 100],
|
||||
"id": "8319a07f-465f-4456-b6b0-012aa36ce08b",
|
||||
"name": "No Operation, do nothing"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"attributes": {
|
||||
"type": "Account",
|
||||
"url": "/services/data/v59.0/sobjects/Account/id1"
|
||||
},
|
||||
"Id": "id1",
|
||||
"Name": "Account 1",
|
||||
"Type": "Customer - Direct"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"attributes": {
|
||||
"type": "Account",
|
||||
"url": "/services/data/v59.0/sobjects/Account/id2"
|
||||
},
|
||||
"Id": "id2",
|
||||
"Name": "Account 2",
|
||||
"Type": "Customer - Direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Salesforce",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Salesforce": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "ca0a5b64-d016-4412-8ea9-0cefc269f2c2",
|
||||
"meta": {
|
||||
"instanceId": "e115be144a6a5547dbfca93e774dfffa178aa94a181854c13e2ce5e14d195b2e"
|
||||
},
|
||||
"id": "ODC4s7m6nIRLkeF0",
|
||||
"tags": []
|
||||
}
|
||||
Reference in New Issue
Block a user