diff --git a/src/constants/destinationCanonicalNames.js b/src/constants/destinationCanonicalNames.js index f99c735e45..e9b7dc136b 100644 --- a/src/constants/destinationCanonicalNames.js +++ b/src/constants/destinationCanonicalNames.js @@ -1,6 +1,7 @@ const DestHandlerMap = { ga360: 'ga', salesforce_oauth: 'salesforce', + salesforce_oauth_sandbox: 'salesforce', }; const DestCanonicalNames = { diff --git a/src/features.json b/src/features.json index 097e4a8aa0..ca5f82b337 100644 --- a/src/features.json +++ b/src/features.json @@ -26,6 +26,7 @@ "PROFITWELL": true, "SALESFORCE": true, "SALESFORCE_OAUTH": true, + "SALESFORCE_OAUTH_SANDBOX": true, "SFMC": true, "SNAPCHAT_CONVERSION": true, "TIKTOK_ADS": true, diff --git a/src/v0/destinations/salesforce/config.js b/src/v0/destinations/salesforce/config.js index 1425bad51b..f2e8072755 100644 --- a/src/v0/destinations/salesforce/config.js +++ b/src/v0/destinations/salesforce/config.js @@ -24,6 +24,7 @@ const SF_TOKEN_REQUEST_URL = 'https://login.salesforce.com/services/oauth2/token const SF_TOKEN_REQUEST_URL_SANDBOX = 'https://test.salesforce.com/services/oauth2/token'; const DESTINATION = 'Salesforce'; +const SALESFORCE_OAUTH_SANDBOX = 'salesforce_oauth_sandbox'; const OAUTH = 'oauth'; const LEGACY = 'legacy'; @@ -41,4 +42,5 @@ module.exports = { DESTINATION, OAUTH, LEGACY, + SALESFORCE_OAUTH_SANDBOX, }; diff --git a/src/v0/destinations/salesforce/transform.js b/src/v0/destinations/salesforce/transform.js index 1dde1ec65a..7e66dd8810 100644 --- a/src/v0/destinations/salesforce/transform.js +++ b/src/v0/destinations/salesforce/transform.js @@ -293,6 +293,7 @@ async function processIdentify( authorizationData, authorizationFlow, ) { + const { Name } = destination.DestinationDefinition; const mapProperty = destination.Config.mapProperty === undefined ? true : destination.Config.mapProperty; // check the traits before hand @@ -304,7 +305,7 @@ async function processIdentify( // Append external ID to traits if event is mapped to destination and only if identifier type is not id // If identifier type is id, then it should not be added to traits, else saleforce will throw an error const mappedToDestination = get(message, MappedToDestinationKey); - const externalId = getDestinationExternalIDObjectForRetl(message, 'SALESFORCE'); + const externalId = getDestinationExternalIDObjectForRetl(message, Name); if (mappedToDestination && externalId?.identifierType?.toLowerCase() !== 'id') { addExternalIdToTraits(message); } diff --git a/src/v0/destinations/salesforce/utils.js b/src/v0/destinations/salesforce/utils.js index 9a4effc502..a7731f07de 100644 --- a/src/v0/destinations/salesforce/utils.js +++ b/src/v0/destinations/salesforce/utils.js @@ -1,4 +1,9 @@ -const { RetryableError, ThrottledError, AbortedError } = require('@rudderstack/integrations-lib'); +const { + RetryableError, + ThrottledError, + AbortedError, + OAuthSecretError, +} = require('@rudderstack/integrations-lib'); const { handleHttpRequest } = require('../../../adapters/network'); const { isHttpStatusSuccess, @@ -13,6 +18,7 @@ const { DESTINATION, LEGACY, OAUTH, + SALESFORCE_OAUTH_SANDBOX, } = require('./config'); const ACCESS_TOKEN_CACHE = new Cache(ACCESS_TOKEN_CACHE_TTL); @@ -104,10 +110,15 @@ const salesforceResponseHandler = (destResponse, sourceMessage, authKey, authori * @param {destination: Record, metadata: Record} * @returns */ -const getAccessTokenOauth = (metadata) => ({ - token: metadata.secret?.access_token, - instanceUrl: metadata.secret?.instance_url, -}); +const getAccessTokenOauth = (metadata) => { + if (!isDefinedAndNotNull(metadata?.secret)) { + throw new OAuthSecretError('secret is undefined/null'); + } + return { + token: metadata.secret?.access_token, + instanceUrl: metadata.secret?.instance_url, + }; +}; const getAccessToken = async ({ destination, metadata }) => { const accessTokenKey = destination.ID; @@ -169,7 +180,9 @@ const getAccessToken = async ({ destination, metadata }) => { const collectAuthorizationInfo = async (event) => { let authorizationFlow; let authorizationData; - if (isDefinedAndNotNull(event.metadata?.secret)) { + const { Name } = event.destination.DestinationDefinition; + const lowerCaseName = Name?.toLowerCase?.(); + if (isDefinedAndNotNull(event?.metadata?.secret) || lowerCaseName === SALESFORCE_OAUTH_SANDBOX) { authorizationFlow = OAUTH; authorizationData = getAccessTokenOauth(event.metadata); } else { diff --git a/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js b/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js new file mode 100644 index 0000000000..b6cbed77f9 --- /dev/null +++ b/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js @@ -0,0 +1,34 @@ +const { proxyRequest, prepareProxyRequest } = require('../../../adapters/network'); +const { processAxiosResponse } = require('../../../adapters/utils/networkUtils'); +const { OAUTH } = require('../salesforce/config'); +const { salesforceResponseHandler } = require('../salesforce/utils'); + +const responseHandler = (responseParams) => { + const { destinationResponse, destType, rudderJobMetadata } = responseParams; + const message = `Request for destination: ${destType} Processed Successfully`; + + salesforceResponseHandler( + destinationResponse, + 'during Salesforce Response Handling', + rudderJobMetadata?.destInfo?.authKey, + OAUTH, + ); + + // else successfully return status as 200, message and original destination response + return { + status: 200, + message, + destinationResponse, + }; +}; + +function networkHandler() { + this.responseHandler = responseHandler; + this.proxy = proxyRequest; + this.prepareProxy = prepareProxyRequest; + this.processAxiosResponse = processAxiosResponse; +} + +module.exports = { + networkHandler, +}; diff --git a/test/integrations/destinations/salesforce/processor/data.ts b/test/integrations/destinations/salesforce/processor/data.ts index 7a44b6b8ed..b33b75b55b 100644 --- a/test/integrations/destinations/salesforce/processor/data.ts +++ b/test/integrations/destinations/salesforce/processor/data.ts @@ -1373,7 +1373,7 @@ export const data = [ }, { name: 'salesforce', - description: 'Test 10', + description: 'Test 11', feature: 'processor', module: 'destination', version: 'v0', @@ -1389,9 +1389,9 @@ export const data = [ sandbox: true, }, DestinationDefinition: { - DisplayName: 'Salesforce', + DisplayName: 'Salesforce Sandbox', ID: '1T96GHZ0YZ1qQSLULHCoJkow9KC', - Name: 'SALESFORCE', + Name: 'SALESFORCE_OAUTH_SANDBOX', }, Enabled: true, ID: '1ut7LcVW1QC56y2EoTNo7ZwBWSY', @@ -1412,7 +1412,7 @@ export const data = [ externalId: [ { id: 'a005g0000383kmUAAQ', - type: 'SALESFORCE-custom_object__c', + type: 'SALESFORCE_OAUTH_SANDBOX-custom_object__c', identifierType: 'Id', }, ], @@ -1499,4 +1499,112 @@ export const data = [ }, }, }, + { + name: 'salesforce', + description: 'Test 12 : Retry happens when no secret information is found', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination: { + Config: { + initialAccessToken: '7fiy1FKcO9sohsxq1v6J88sg', + password: 'dummyPassword2', + userName: 'test.c97-qvpd@force.com.test', + sandbox: true, + }, + DestinationDefinition: { + DisplayName: 'Salesforce Sandbox', + ID: '1T96GHZ0YZ1qQSLULHCoJkow9KC', + Name: 'SALESFORCE_OAUTH_SANDBOX', + }, + Enabled: true, + ID: '1ut7LcVW1QC56y2EoTNo7ZwBWSY', + Name: 'Test SF', + Transformations: [], + }, + metadata: { + jobId: 1, + }, + message: { + anonymousId: '1e7673da-9473-49c6-97f7-da848ecafa76', + channel: 'web', + context: { + mappedToDestination: true, + externalId: [ + { + id: 'a005g0000383kmUAAQ', + type: 'SALESFORCE_OAUTH_SANDBOX-custom_object__c', + identifierType: 'Id', + }, + ], + app: { + build: '1.0.0', + name: 'RudderLabs JavaScript SDK', + namespace: 'com.rudderlabs.javascript', + version: '1.0.0', + }, + ip: '0.0.0.0', + library: { + name: 'RudderLabs JavaScript SDK', + version: '1.0.0', + }, + locale: 'en-US', + os: { + name: '', + version: '', + }, + screen: { + density: 2, + }, + traits: { + email: 'john@rs.com', + firstname: 'john doe', + Id: 'some-id', + }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36', + }, + integrations: { + All: true, + }, + messageId: 'f19c35da-e9de-4c6e-b6e5-9e60cccc12c8', + originalTimestamp: '2020-01-27T12:20:55.301Z', + receivedAt: '2020-01-27T17:50:58.657+05:30', + request_ip: '14.98.244.60', + sentAt: '2020-01-27T12:20:56.849Z', + timestamp: '2020-01-27T17:50:57.109+05:30', + type: 'identify', + userId: '1e7673da-9473-49c6-97f7-da848ecafa76', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + statusCode: 500, + error: 'secret is undefined/null', + metadata: { + jobId: 1, + }, + statTags: { + errorCategory: 'platform', + errorType: 'oAuthSecret', + destType: 'SALESFORCE', + module: 'destination', + implementation: 'native', + feature: 'processor', + }, + }, + ], + }, + }, + }, ]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts new file mode 100644 index 0000000000..bed8eec8db --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts @@ -0,0 +1,3 @@ +import { testScenariosForV1API } from './oauth'; + +export const data = [...testScenariosForV1API]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts new file mode 100644 index 0000000000..30ee516e72 --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts @@ -0,0 +1,174 @@ +import { ProxyMetdata } from '../../../../../src/types'; +import { ProxyV1TestData } from '../../../testTypes'; +import { generateProxyV1Payload } from '../../../testUtils'; + +const commonHeadersForWrongToken = { + Authorization: 'Bearer expiredAccessToken', + 'Content-Type': 'application/json', +}; + +const commonHeadersForRightToken = { + Authorization: 'Bearer correctAccessToken', + 'Content-Type': 'application/json', +}; +const params = { destination: 'salesforce_oauth_sandbox' }; + +const users = [ + { + Email: 'danis.archurav@sbermarket.ru', + Company: 'itus.ru', + LastName: 'Danis', + FirstName: 'Archurav', + LeadSource: 'App Signup', + account_type__c: 'free_trial', + }, +]; + +const statTags = { + retryable: { + destType: 'SALESFORCE_OAUTH_SANDBOX', + destinationId: 'dummyDestinationId', + errorCategory: 'network', + errorType: 'retryable', + feature: 'dataDelivery', + implementation: 'native', + module: 'destination', + workspaceId: 'dummyWorkspaceId', + }, +}; + +const commonRequestParametersWithWrongToken = { + headers: commonHeadersForWrongToken, + JSON: users[0], + params, +}; + +const commonRequestParametersWithRightToken = { + headers: commonHeadersForRightToken, + JSON: users[0], + params, +}; + +export const proxyMetdataWithSecretWithWrongAccessToken: ProxyMetdata = { + jobId: 1, + attemptNum: 1, + userId: 'dummyUserId', + sourceId: 'dummySourceId', + destinationId: 'dummyDestinationId', + workspaceId: 'dummyWorkspaceId', + secret: { + access_token: 'expiredAccessToken', + instanceUrl: 'https://rudderstack.my.salesforce_oauth_sandbox.com', + }, + destInfo: { authKey: 'dummyDestinationId' }, + dontBatch: false, +}; + +export const proxyMetdataWithSecretWithRightAccessToken: ProxyMetdata = { + jobId: 1, + attemptNum: 1, + userId: 'dummyUserId', + sourceId: 'dummySourceId', + destinationId: 'dummyDestinationId', + workspaceId: 'dummyWorkspaceId', + secret: { + access_token: 'expiredRightToken', + instanceUrl: 'https://rudderstack.my.salesforce_oauth_sandbox.com', + }, + destInfo: { authKey: 'dummyDestinationId' }, + dontBatch: false, +}; + +export const reqMetadataArrayWithWrongSecret = [proxyMetdataWithSecretWithWrongAccessToken]; +export const reqMetadataArray = [proxyMetdataWithSecretWithRightAccessToken]; + +export const testScenariosForV1API: ProxyV1TestData[] = [ + { + id: 'salesforce_v1_scenario_1', + name: 'salesforce_oauth_sandbox', + description: '[Proxy v1 API] :: Test with expired access token scenario', + successCriteria: + 'Should return 5XX with error category REFRESH_TOKEN and Session expired or invalid, INVALID_SESSION_ID', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload( + { + ...commonRequestParametersWithWrongToken, + endpoint: + 'https://rudderstack.my.salesforce_oauth_sandbox.com/services/data/v50.0/sobjects/Lead/20', + }, + reqMetadataArrayWithWrongSecret, + ), + method: 'POST', + }, + }, + output: { + response: { + status: 500, + body: { + output: { + status: 500, + authErrorCategory: 'REFRESH_TOKEN', + message: + 'Salesforce Request Failed - due to "INVALID_SESSION_ID", (Retryable) during Salesforce Response Handling', + response: [ + { + error: + '[{"message":"Session expired or invalid","errorCode":"INVALID_SESSION_ID"}]', + metadata: proxyMetdataWithSecretWithWrongAccessToken, + statusCode: 500, + }, + ], + statTags: statTags.retryable, + }, + }, + }, + }, + }, + { + id: 'salesforce_v1_scenario_2', + name: 'salesforce', + description: + '[Proxy v1 API] :: Test for a valid request - Lead creation with existing unchanged leadId and unchanged data', + successCriteria: 'Should return 200 with no error with destination response', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload( + { + ...commonRequestParametersWithRightToken, + endpoint: + 'https://rudderstack.my.salesforce.com/services/data/v50.0/sobjects/Lead/existing_unchanged_leadId', + }, + reqMetadataArray, + ), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 200, + message: 'Request for destination: salesforce Processed Successfully', + response: [ + { + error: '{"statusText":"No Content"}', + metadata: proxyMetdataWithSecretWithRightAccessToken, + statusCode: 200, + }, + ], + }, + }, + }, + }, + }, +]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/network.ts b/test/integrations/destinations/salesforce_oauth_sandbox/network.ts new file mode 100644 index 0000000000..09d2c759d2 --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/network.ts @@ -0,0 +1,51 @@ +const headerWithWrongAccessToken = { + Authorization: 'Bearer expiredAccessToken', + 'Content-Type': 'application/json', +}; + +const headerWithRightAccessToken = { + Authorization: 'Bearer correctAccessToken', + 'Content-Type': 'application/json', +}; + +const dataValue = { + Email: 'danis.archurav@sbermarket.ru', + Company: 'itus.ru', + LastName: 'Danis', + FirstName: 'Archurav', + LeadSource: 'App Signup', + account_type__c: 'free_trial', +}; + +const businessMockData = [ + { + description: 'Mock response from destination depicting an expired access token', + httpReq: { + method: 'post', + url: 'https://rudderstack.my.salesforce_oauth_sandbox.com/services/data/v50.0/sobjects/Lead/20', + headers: headerWithWrongAccessToken, + data: dataValue, + params: { destination: 'salesforce_oauth_sandbox' }, + }, + httpRes: { + data: [{ message: 'Session expired or invalid', errorCode: 'INVALID_SESSION_ID' }], + status: 401, + }, + }, + { + description: + 'Mock response from destination depicting a valid lead request, with no changed data', + httpReq: { + method: 'post', + url: 'https://rudderstack.my.salesforce.com/services/data/v50.0/sobjects/Lead/existing_unchanged_leadId', + data: dataValue, + headers: headerWithRightAccessToken, + }, + httpRes: { + data: { statusText: 'No Content' }, + status: 204, + }, + }, +]; + +export const networkCallsData = [...businessMockData];