diff --git a/src/dispatch/Dispatch.ts b/src/dispatch/Dispatch.ts index 4dcaf07f..3ab2dfd2 100644 --- a/src/dispatch/Dispatch.ts +++ b/src/dispatch/Dispatch.ts @@ -12,6 +12,9 @@ import { Config } from '../orchestration/Orchestration'; import { v4 } from 'uuid'; import { RetryHttpHandler } from './RetryHttpHandler'; import { InternalLogger } from '../utils/InternalLogger'; +import { CRED_KEY, IDENTITY_KEY } from '../utils/constants'; +import { BasicAuthentication } from '../dispatch/BasicAuthentication'; +import { EnhancedAuthentication } from '../dispatch/EnhancedAuthentication'; type SendFunction = ( putRumEventsRequest: PutRumEventsRequest @@ -31,6 +34,7 @@ export type ClientBuilder = ( ) => DataPlaneClient; export class Dispatch { + private applicationId: string; private region: string; private endpoint: URL; private eventCache: EventCache; @@ -41,13 +45,23 @@ export class Dispatch { private config: Config; private disableCodes = ['403', '404']; private headers: any; + private credentialProvider: + | AwsCredentialIdentity + | AwsCredentialIdentityProvider + | undefined; + + private shouldPurgeCredentials = true; + private credentialStorageKey: string; + private identityStorageKey: string; constructor( + applicationId: string, region: string, endpoint: URL, eventCache: EventCache, config: Config ) { + this.applicationId = applicationId; this.region = region; this.endpoint = endpoint; this.eventCache = eventCache; @@ -68,6 +82,14 @@ export class Dispatch { } else { this.rum = this.buildClient(this.endpoint, this.region, undefined); } + + this.credentialStorageKey = this.config.cookieAttributes.unique + ? `${CRED_KEY}_${applicationId}` + : CRED_KEY; + + this.identityStorageKey = this.config.cookieAttributes.unique + ? `${IDENTITY_KEY}_${applicationId}` + : IDENTITY_KEY; } /** @@ -100,6 +122,7 @@ export class Dispatch { | AwsCredentialIdentity | AwsCredentialIdentityProvider ): void { + this.credentialProvider = credentialProvider; this.rum = this.buildClient( this.endpoint, this.region, @@ -112,6 +135,23 @@ export class Dispatch { } } + public setCognitoCredentials( + identityPoolId: string, + guestRoleArn?: string + ) { + if (identityPoolId && guestRoleArn) { + this.setAwsCredentials( + new BasicAuthentication(this.config, this.applicationId) + .ChainAnonymousCredentialsProvider + ); + } else { + this.setAwsCredentials( + new EnhancedAuthentication(this.config, this.applicationId) + .ChainAnonymousCredentialsProvider + ); + } + } + /** * Send meta data and events to the AWS RUM data plane service via fetch. */ @@ -273,17 +313,32 @@ export class Dispatch { } private handleReject = (e: any): { response: HttpResponse } => { - if (e instanceof Error && this.disableCodes.includes(e.message)) { - // RUM disables only when dispatch fails and we are certain - // that subsequent attempts will not succeed, such as when - // credentials are invalid or the app monitor does not exist. - if (this.config.debug) { - InternalLogger.error( - 'Dispatch failed with status code:', - e.message - ); + if (e instanceof Error) { + if ( + e.message === '403' && + this.config.signing && + this.shouldPurgeCredentials + ) { + // If auth fails and a credentialProvider has been configured, + // then we need to make sure that the cached credentials are for + // the intended RUM app monitor. Otherwise, the irrelevant cached + // credentials will never get evicted. + // + // The next retry or request will be made with fresh credentials. + this.shouldPurgeCredentials = false; + this.forceRebuildClient(); + } else if (this.disableCodes.includes(e.message)) { + // RUM disables only when dispatch fails and we are certain + // that subsequent attempts will not succeed, such as when + // credentials are invalid or the app monitor does not exist. + if (this.config.debug) { + InternalLogger.error( + 'Dispatch failed with status code:', + e.message + ); + } + this.disable(); } - this.disable(); } throw e; }; @@ -314,4 +369,31 @@ export class Dispatch { headers: this.headers }); }; + + /** + * Purges the cached credentials and rebuilds the dataplane client. This is only necessary + * if signing is enabled and the cached credentials are for the wrong app monitor. + * + * @param credentialProvider - The credential or provider use to sign requests + */ + private forceRebuildClient() { + InternalLogger.warn('Removing credentials from local storage'); + localStorage.removeItem(this.credentialStorageKey); + + if (this.config.identityPoolId) { + InternalLogger.info( + 'Rebuilding client with fresh cognito credentials' + ); + localStorage.removeItem(this.identityStorageKey); + this.setCognitoCredentials( + this.config.identityPoolId, + this.config.guestRoleArn + ); + } else if (this.credentialProvider) { + InternalLogger.info( + 'Rebuilding client with most recently passed provider' + ); + this.setAwsCredentials(this.credentialProvider); + } + } } diff --git a/src/dispatch/__tests__/Dispatch.test.ts b/src/dispatch/__tests__/Dispatch.test.ts index 38d56c8b..3ff003b7 100644 --- a/src/dispatch/__tests__/Dispatch.test.ts +++ b/src/dispatch/__tests__/Dispatch.test.ts @@ -4,6 +4,9 @@ import { DataPlaneClient } from '../DataPlaneClient'; import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; import { DEFAULT_CONFIG, mockFetch } from '../../test-utils/test-utils'; import { EventCache } from 'event-cache/EventCache'; +import { CRED_KEY, IDENTITY_KEY } from '../../utils/constants'; +import { BasicAuthentication } from '../BasicAuthentication'; +import { EnhancedAuthentication } from '../EnhancedAuthentication'; global.fetch = mockFetch; const sendFetch = jest.fn(() => Promise.resolve()); @@ -14,6 +17,21 @@ jest.mock('../DataPlaneClient', () => ({ .mockImplementation(() => ({ sendFetch, sendBeacon })) })); +const mockBasicAuthProvider = jest.fn(); +const mockEnhancedAuthProvider = jest.fn(); + +jest.mock('../BasicAuthentication', () => ({ + BasicAuthentication: jest.fn().mockImplementation(() => ({ + ChainAnonymousCredentialsProvider: mockBasicAuthProvider + })) +})); + +jest.mock('../EnhancedAuthentication', () => ({ + EnhancedAuthentication: jest.fn().mockImplementation(() => ({ + ChainAnonymousCredentialsProvider: mockEnhancedAuthProvider + })) +})); + let visibilityState = 'visible'; Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -27,6 +45,7 @@ describe('Dispatch tests', () => { sendFetch.mockClear(); sendBeacon.mockClear(); visibilityState = 'visible'; + jest.clearAllMocks(); }); afterEach(() => { @@ -38,6 +57,7 @@ describe('Dispatch tests', () => { test('dispatch() sends data through client', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -60,6 +80,7 @@ describe('Dispatch tests', () => { // Init const credentialProvider: AwsCredentialIdentityProvider = jest.fn(); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -83,6 +104,7 @@ describe('Dispatch tests', () => { ); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -103,6 +125,7 @@ describe('Dispatch tests', () => { test('dispatch() does nothing when disabled', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -125,6 +148,7 @@ describe('Dispatch tests', () => { test('dispatch() sends when disabled then enabled', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -148,6 +172,7 @@ describe('Dispatch tests', () => { test('dispatch() automatically dispatches when interval > 0', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -171,6 +196,7 @@ describe('Dispatch tests', () => { test('dispatch() does not automatically dispatch when interval = 0', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -194,6 +220,7 @@ describe('Dispatch tests', () => { test('dispatch() does not automatically dispatch when interval < 0', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -217,6 +244,7 @@ describe('Dispatch tests', () => { test('dispatch() does not automatically dispatch when dispatch is disabled', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -241,6 +269,7 @@ describe('Dispatch tests', () => { test('dispatch() resumes when disabled and enabled', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -269,6 +298,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -296,6 +326,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -323,6 +354,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -350,6 +382,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -378,6 +411,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -412,6 +446,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); const getEventBatch = jest.spyOn(eventCache, 'getEventBatch'); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -442,6 +477,7 @@ describe('Dispatch tests', () => { test('when plugin is disabled then beacon dispatch does not run', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -464,6 +500,7 @@ describe('Dispatch tests', () => { test('when dispatch does not have AWS credentials then dispatchFetch throws an error', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -482,6 +519,7 @@ describe('Dispatch tests', () => { test('when dispatch does not have AWS credentials then dispatchBeacon throws an error', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -500,6 +538,7 @@ describe('Dispatch tests', () => { test('when dispatch does not have AWS credentials then dispatchFetchFailSilent fails silently', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -518,6 +557,7 @@ describe('Dispatch tests', () => { test('when dispatch does not have AWS credentials then dispatchBeaconFailSilent fails silently', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -542,6 +582,7 @@ describe('Dispatch tests', () => { const eventCache = Utils.createDefaultEventCacheWithEvents(); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -572,6 +613,7 @@ describe('Dispatch tests', () => { Utils.createDefaultEventCacheWithEvents(); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -592,7 +634,7 @@ describe('Dispatch tests', () => { expect((dispatch as unknown as any).enabled).toBe(true); }); - test('when a fetch request is rejected with 403 then dispatch is disabled', async () => { + test('when a fetch request is rejected with 403 then dispatch is disabled ONLY after rebuilding the dataplane client', async () => { // Init sendFetch.mockImplementationOnce(() => Promise.reject(new Error('403')) @@ -602,6 +644,7 @@ describe('Dispatch tests', () => { Utils.createDefaultEventCacheWithEvents(); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -611,6 +654,314 @@ describe('Dispatch tests', () => { } ); dispatch.setAwsCredentials(Utils.createAwsCredentials()); + const client1 = (dispatch as unknown as any).rum as DataPlaneClient; + const forceRebuildClientSpy = jest.spyOn( + dispatch as unknown as any, + 'forceRebuildClient' + ); + + // Run + eventCache.recordEvent('com.amazon.rum.event1', {}); + + // Assert + await expect(dispatch.dispatchFetch()).rejects.toEqual( + new Error('403') + ); + // dispatch should not be disabled on the first 403 + expect((dispatch as unknown as any).enabled).toBe(true); + + // the client should have been rebuilt + const client2 = (dispatch as unknown as any).rum as DataPlaneClient; + expect(client1).not.toBe(client2); + expect(forceRebuildClientSpy).toHaveBeenCalledTimes(1); + + // dispatch should be disabled on the second 403 + sendFetch.mockImplementationOnce(() => + Promise.reject(new Error('403')) + ); + eventCache.recordEvent('com.amazon.rum.event1', {}); + await expect(dispatch.dispatchFetch()).rejects.toEqual( + new Error('403') + ); + expect((dispatch as unknown as any).enabled).toBe(false); + }); + + test('when forceRebuildClient is called, then credentials in local storage are reset and setCognitoCredentials is called', async () => { + // Init + const mockCredentialProvider = Utils.createAwsCredentials(); + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + const setCognitoCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setCognitoCredentials' + ); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + { + ...DEFAULT_CONFIG, + ...{ + dispatchInterval: Utils.AUTO_DISPATCH_OFF, + identityPoolId: + 'us-west-2:12345678-1234-1234-1234-123456789012', + guestRoleArn: 'arn:aws:iam::123456789012:role/TestRole' + } + } + ); + dispatch.setAwsCredentials(mockCredentialProvider); + + // Run + (dispatch as any).forceRebuildClient(); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith(CRED_KEY); + expect(removeItemSpy).toHaveBeenCalledWith(IDENTITY_KEY); + expect(setCognitoCredentialsSpy).toHaveBeenCalledWith( + 'us-west-2:12345678-1234-1234-1234-123456789012', + 'arn:aws:iam::123456789012:role/TestRole' + ); + + removeItemSpy.mockRestore(); + setCognitoCredentialsSpy.mockRestore(); + }); + + test('when setCognitoCredentials is called and guestRoleArn exists, then basic authentication is used', async () => { + // Init + const setAwsCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setAwsCredentials' + ); + const config = { + ...DEFAULT_CONFIG, + ...{ dispatchInterval: Utils.AUTO_DISPATCH_OFF } + }; + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + config + ); + + // Run + dispatch.setCognitoCredentials( + 'us-west-2:12345678-1234-1234-1234-123456789012', + 'arn:aws:iam::123456789012:role/TestRole' + ); + + // Assert + expect(BasicAuthentication).toHaveBeenCalledWith( + config, + Utils.APPLICATION_ID + ); + expect(EnhancedAuthentication).not.toHaveBeenCalled(); + expect(setAwsCredentialsSpy).toHaveBeenCalledWith( + mockBasicAuthProvider + ); + + setAwsCredentialsSpy.mockRestore(); + }); + + test('when setCognitoCredentials is called and guestRoleArn does not exist, then enhanced authentication is used', async () => { + // Init + const setAwsCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setAwsCredentials' + ); + const config = { + ...DEFAULT_CONFIG, + ...{ dispatchInterval: Utils.AUTO_DISPATCH_OFF } + }; + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + config + ); + + // Run + dispatch.setCognitoCredentials( + 'us-west-2:12345678-1234-1234-1234-123456789012' + ); + + // Assert + expect(EnhancedAuthentication).toHaveBeenCalledWith( + config, + Utils.APPLICATION_ID + ); + expect(BasicAuthentication).not.toHaveBeenCalled(); + expect(setAwsCredentialsSpy).toHaveBeenCalledWith( + mockEnhancedAuthProvider + ); + + setAwsCredentialsSpy.mockRestore(); + }); + + test('when forceRebuildClient is called and unique cookies are enabled, then unique storage keys are used', async () => { + // Init + const mockCredentialProvider = Utils.createAwsCredentials(); + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + const setCognitoCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setCognitoCredentials' + ); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + { + ...DEFAULT_CONFIG, + ...{ + dispatchInterval: Utils.AUTO_DISPATCH_OFF, + identityPoolId: + 'us-west-2:12345678-1234-1234-1234-123456789012', + guestRoleArn: 'arn:aws:iam::123456789012:role/TestRole', + cookieAttributes: { + ...DEFAULT_CONFIG.cookieAttributes, + unique: true + } + } + } + ); + dispatch.setAwsCredentials(mockCredentialProvider); + + // Run + (dispatch as any).forceRebuildClient(); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith( + `${CRED_KEY}_${Utils.APPLICATION_ID}` + ); + expect(removeItemSpy).toHaveBeenCalledWith( + `${IDENTITY_KEY}_${Utils.APPLICATION_ID}` + ); + expect(setCognitoCredentialsSpy).toHaveBeenCalledWith( + 'us-west-2:12345678-1234-1234-1234-123456789012', + 'arn:aws:iam::123456789012:role/TestRole' + ); + + removeItemSpy.mockRestore(); + setCognitoCredentialsSpy.mockRestore(); + }); + + test('when forceRebuildClient is called and cognito is not enabled but credentialProvider was set, then client is rebuilt with credentialProvider', async () => { + // Init + const mockCredentialProvider = Utils.createAwsCredentials(); + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + const setAwsCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setAwsCredentials' + ); + const setCognitoCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setCognitoCredentials' + ); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + { + ...DEFAULT_CONFIG, + ...{ + dispatchInterval: Utils.AUTO_DISPATCH_OFF + // No identityPoolId - cognito not enabled + } + } + ); + dispatch.setAwsCredentials(mockCredentialProvider); + + // Run + (dispatch as any).forceRebuildClient(); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith(CRED_KEY); + expect(setCognitoCredentialsSpy).not.toHaveBeenCalled(); + expect(setAwsCredentialsSpy).toHaveBeenCalledWith( + mockCredentialProvider + ); + + removeItemSpy.mockRestore(); + setAwsCredentialsSpy.mockRestore(); + setCognitoCredentialsSpy.mockRestore(); + }); + + test('when forceRebuildClient is called and cognito is not enabled and credentialProvider was not set, then only credentials are cleared', async () => { + // Init + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + const setAwsCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setAwsCredentials' + ); + const setCognitoCredentialsSpy = jest.spyOn( + Dispatch.prototype, + 'setCognitoCredentials' + ); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + Utils.createDefaultEventCacheWithEvents(), + { + ...DEFAULT_CONFIG, + ...{ + dispatchInterval: Utils.AUTO_DISPATCH_OFF + // No identityPoolId - cognito not enabled + } + } + ); + // Do NOT call setAwsCredentials + + // Run + (dispatch as any).forceRebuildClient(); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith(CRED_KEY); + expect(setCognitoCredentialsSpy).not.toHaveBeenCalled(); + expect(setAwsCredentialsSpy).not.toHaveBeenCalled(); + + removeItemSpy.mockRestore(); + setAwsCredentialsSpy.mockRestore(); + setCognitoCredentialsSpy.mockRestore(); + }); + + test('when a fetch request is rejected with 403 and signing is disabled, then dispatch is disabled immediately', async () => { + // Init + sendFetch.mockImplementationOnce(() => + Promise.reject(new Error('403')) + ); + + const eventCache: EventCache = + Utils.createDefaultEventCacheWithEvents(); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + eventCache, + { + ...DEFAULT_CONFIG, + ...{ + dispatchInterval: Utils.AUTO_DISPATCH_OFF, + retries: 0, + signing: false + } + } + ); + + const forceRebuildClientSpy = jest.spyOn( + dispatch as unknown as any, + 'forceRebuildClient' + ); // Run eventCache.recordEvent('com.amazon.rum.event1', {}); @@ -619,7 +970,55 @@ describe('Dispatch tests', () => { await expect(dispatch.dispatchFetch()).rejects.toEqual( new Error('403') ); + // dispatch should be disabled immediately when signing is disabled expect((dispatch as unknown as any).enabled).toBe(false); + // forceRebuildClient should not be called when signing is disabled + expect(forceRebuildClientSpy).not.toHaveBeenCalled(); + + forceRebuildClientSpy.mockRestore(); + }); + + test('when a fetch request is successful after rebuilding the dataplane client, then dispatch is not disabled', async () => { + // Init + sendFetch.mockImplementationOnce(() => + Promise.reject(new Error('403')) + ); + + const eventCache: EventCache = + Utils.createDefaultEventCacheWithEvents(); + + dispatch = new Dispatch( + Utils.APPLICATION_ID, + Utils.AWS_RUM_REGION, + Utils.AWS_RUM_ENDPOINT, + eventCache, + { + ...DEFAULT_CONFIG, + ...{ dispatchInterval: Utils.AUTO_DISPATCH_OFF, retries: 0 } + } + ); + dispatch.setAwsCredentials(Utils.createAwsCredentials()); + const client1 = (dispatch as unknown as any).rum as DataPlaneClient; + + // Run + eventCache.recordEvent('com.amazon.rum.event1', {}); + + // Assert + await expect(dispatch.dispatchFetch()).rejects.toEqual( + new Error('403') + ); + // dispatch should not be disabled on the first 403 + expect((dispatch as unknown as any).enabled).toBe(true); + + // the client should have been rebuilt + const client2 = (dispatch as unknown as any).rum as DataPlaneClient; + expect(client1).not.toBe(client2); + + // dispatch should not be disabled if credentials are fixed + sendFetch.mockImplementationOnce(() => Promise.resolve()); + eventCache.recordEvent('com.amazon.rum.event1', {}); + dispatch.dispatchFetch(); + expect((dispatch as unknown as any).enabled).toBe(true); }); test('when a fetch request is rejected with 404 then dispatch is disabled', async () => { @@ -632,6 +1031,7 @@ describe('Dispatch tests', () => { Utils.createDefaultEventCacheWithEvents(); dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, eventCache, @@ -655,6 +1055,7 @@ describe('Dispatch tests', () => { test('when signing is disabled then credentials are not needed for dispatch', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -675,6 +1076,7 @@ describe('Dispatch tests', () => { test('When valid alias is provided then PutRumEvents request containing alias is sent', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), @@ -702,6 +1104,7 @@ describe('Dispatch tests', () => { test('When no alias is provided then PutRumEvents request does not contain an alias', async () => { // Init dispatch = new Dispatch( + Utils.APPLICATION_ID, Utils.AWS_RUM_REGION, Utils.AWS_RUM_ENDPOINT, Utils.createDefaultEventCacheWithEvents(), diff --git a/src/orchestration/Orchestration.ts b/src/orchestration/Orchestration.ts index 24d8cb3d..cf928eef 100644 --- a/src/orchestration/Orchestration.ts +++ b/src/orchestration/Orchestration.ts @@ -411,28 +411,17 @@ export class Orchestration { private initDispatch(region: string, applicationId: string) { const dispatch: Dispatch = new Dispatch( + applicationId, region, this.config.endpointUrl, this.eventCache, this.config ); - // Only retrieves and sets credentials if the session is sampled. - // The nil session created during initialization will have the same sampling decision as - // the new session created when the first event is recorded. - if (!this.eventCache.isSessionSampled()) { - return dispatch; - } - - if (this.config.identityPoolId && this.config.guestRoleArn) { - dispatch.setAwsCredentials( - new BasicAuthentication(this.config, applicationId) - .ChainAnonymousCredentialsProvider - ); - } else if (this.config.identityPoolId) { - dispatch.setAwsCredentials( - new EnhancedAuthentication(this.config, applicationId) - .ChainAnonymousCredentialsProvider + if (this.config.signing && this.config.identityPoolId) { + dispatch.setCognitoCredentials( + this.config.identityPoolId, + this.config.guestRoleArn ); } diff --git a/src/orchestration/__tests__/Orchestration.test.ts b/src/orchestration/__tests__/Orchestration.test.ts index 133ac6e5..2a111b63 100644 --- a/src/orchestration/__tests__/Orchestration.test.ts +++ b/src/orchestration/__tests__/Orchestration.test.ts @@ -18,12 +18,14 @@ global.fetch = jest.fn(); const enableDispatch = jest.fn(); const disableDispatch = jest.fn(); const setAwsCredentials = jest.fn(); +const setCognitoCredentials = jest.fn(); jest.mock('../../dispatch/Dispatch', () => ({ Dispatch: jest.fn().mockImplementation(() => ({ enable: enableDispatch, disable: disableDispatch, - setAwsCredentials + setAwsCredentials, + setCognitoCredentials })) })); @@ -85,9 +87,10 @@ describe('Orchestration tests', () => { // Init const orchestration = new Orchestration('a', 'c', undefined, {}); + const dispatchmock = (Dispatch as any).mock; // Assert expect(Dispatch).toHaveBeenCalledTimes(1); - expect((Dispatch as any).mock.calls[0][1]).toEqual( + expect((Dispatch as any).mock.calls[0][2]).toEqual( new URL('https://dataplane.rum.us-west-2.amazonaws.com/') ); }); @@ -98,7 +101,7 @@ describe('Orchestration tests', () => { // Assert expect(Dispatch).toHaveBeenCalledTimes(1); - expect((Dispatch as any).mock.calls[0][1]).toEqual( + expect((Dispatch as any).mock.calls[0][2]).toEqual( new URL('https://dataplane.rum.us-east-1.amazonaws.com/') ); }); @@ -504,17 +507,17 @@ describe('Orchestration tests', () => { ); }); - test('when session is not recorded then credentials are not set', async () => { + test('when session is not recorded then credentials are set', async () => { samplingDecision = false; // Init - const orchestration = new Orchestration('a', 'c', 'us-east-1', { + new Orchestration('a', 'c', 'us-east-1', { telemetries: [], identityPoolId: 'dummyPoolId', guestRoleArn: 'dummyRoleArn' }); // Assert - expect(setAwsCredentials).toHaveBeenCalledTimes(0); + expect(setCognitoCredentials).toHaveBeenCalledTimes(1); // Reset samplingDecision = true; @@ -529,7 +532,68 @@ describe('Orchestration tests', () => { }); // Assert - expect(setAwsCredentials).toHaveBeenCalledTimes(1); + expect(setCognitoCredentials).toHaveBeenCalledTimes(1); + }); + + test('when identityPoolId and guestRoleArn are configured then setCognitoCredentials is called with both arguments', async () => { + // Init + const identityPoolId = 'us-west-2:12345678-1234-1234-1234-123456789012'; + const guestRoleArn = 'arn:aws:iam::123456789012:role/TestGuestRole'; + + new Orchestration('testApp', '1.0.0', 'us-west-2', { + identityPoolId, + guestRoleArn + }); + + // Assert + expect(setCognitoCredentials).toHaveBeenCalledTimes(1); + expect(setCognitoCredentials).toHaveBeenCalledWith( + identityPoolId, + guestRoleArn + ); + }); + + test('when only identityPoolId is configured then setCognitoCredentials is called with identityPoolId and undefined guestRoleArn', async () => { + // Init + const identityPoolId = 'us-west-2:12345678-1234-1234-1234-123456789012'; + + new Orchestration('testApp', '1.0.0', 'us-west-2', { + identityPoolId + // No guestRoleArn provided + }); + + // Assert + expect(setCognitoCredentials).toHaveBeenCalledTimes(1); + expect(setCognitoCredentials).toHaveBeenCalledWith( + identityPoolId, + undefined + ); + }); + + test('when identityPoolId is not configured then setCognitoCredentials is not called', async () => { + // Init + new Orchestration('testApp', '1.0.0', 'us-west-2', { + // No identityPoolId provided + guestRoleArn: 'arn:aws:iam::123456789012:role/TestGuestRole' + }); + + // Assert + expect(setCognitoCredentials).not.toHaveBeenCalled(); + }); + + test('when signing is disabled then setCognitoCredentials is not called even if identityPoolId is configured', async () => { + // Init + const identityPoolId = 'us-west-2:12345678-1234-1234-1234-123456789012'; + const guestRoleArn = 'arn:aws:iam::123456789012:role/TestGuestRole'; + + new Orchestration('testApp', '1.0.0', 'us-west-2', { + identityPoolId, + guestRoleArn, + signing: false + }); + + // Assert + expect(setCognitoCredentials).not.toHaveBeenCalled(); }); });