diff --git a/packages/compass-e2e-tests/tests/proxy.test.ts b/packages/compass-e2e-tests/tests/proxy.test.ts index 059e678cf9d..0e9450b3f02 100644 --- a/packages/compass-e2e-tests/tests/proxy.test.ts +++ b/packages/compass-e2e-tests/tests/proxy.test.ts @@ -65,10 +65,12 @@ describe('Proxy support', function () { browser = compass.browser; const result = await browser.execute(async function () { - const response = await fetch('http://compass.mongodb.com/'); + const response = await fetch('http://proxy-test-compass.mongodb.com/'); return await response.text(); }); - expect(result).to.equal('hello, http://compass.mongodb.com/ (proxy1)'); + expect(result).to.equal( + 'hello, http://proxy-test-compass.mongodb.com/ (proxy1)' + ); }); it('can change the proxy option dynamically', async function () { @@ -80,10 +82,12 @@ describe('Proxy support', function () { `http://localhost:${port(httpProxyServer2)}` ); const result = await browser.execute(async function () { - const response = await fetch('http://compass.mongodb.com/'); + const response = await fetch('http://proxy-test-compass.mongodb.com/'); return await response.text(); }); - expect(result).to.equal('hello, http://compass.mongodb.com/ (proxy2)'); + expect(result).to.equal( + 'hello, http://proxy-test-compass.mongodb.com/ (proxy2)' + ); }); context('when connecting to a cluster', function () { diff --git a/packages/compass-intercom/src/setup-intercom.spec.ts b/packages/compass-intercom/src/setup-intercom.spec.ts index 8e0133ad280..2a69c55063e 100644 --- a/packages/compass-intercom/src/setup-intercom.spec.ts +++ b/packages/compass-intercom/src/setup-intercom.spec.ts @@ -2,7 +2,7 @@ import type { SinonStub } from 'sinon'; import sinon from 'sinon'; -import { setupIntercom } from './setup-intercom'; +import { setupIntercom, resetIntercomAllowedCache } from './setup-intercom'; import { expect } from 'chai'; import type { IntercomScript } from './intercom-script'; import type { PreferencesAccess } from 'compass-preferences-model'; @@ -11,6 +11,36 @@ import { type User, } from 'compass-preferences-model'; +// Picking something which won't be blocked by CORS +const FAKE_HADRON_AUTO_UPDATE_ENDPOINT = 'https://compass.mongodb.com'; + +function createMockFetch({ + integrations, +}: { + integrations: Record; +}): typeof globalThis.fetch { + return (url) => { + if (typeof url !== 'string') { + throw new Error('Expected url to be a string'); + } + if (url.startsWith(FAKE_HADRON_AUTO_UPDATE_ENDPOINT)) { + if (url === `${FAKE_HADRON_AUTO_UPDATE_ENDPOINT}/api/v2/integrations`) { + return Promise.resolve({ + ok: true, + json() { + return Promise.resolve(integrations); + }, + } as Response); + } + } else if (url === 'https://widget.intercom.io/widget/appid123') { + // NOTE: we use 301 since intercom will redirects + // to the actual location of the widget script + return Promise.resolve({ status: 301 } as Response); + } + throw new Error(`Unexpected URL called on the fake update server: ${url}`); + }; +} + const mockUser: User = { id: 'user-123', createdAt: new Date(1649432549945), @@ -19,7 +49,10 @@ const mockUser: User = { describe('setupIntercom', function () { let backupEnv: Partial; - let fetchMock: SinonStub; + let fetchMock: SinonStub< + Parameters, + ReturnType + >; let preferences: PreferencesAccess; async function testRunSetupIntercom() { @@ -36,6 +69,7 @@ describe('setupIntercom', function () { beforeEach(async function () { backupEnv = { + HADRON_AUTO_UPDATE_ENDPOINT: process.env.HADRON_AUTO_UPDATE_ENDPOINT, HADRON_METRICS_INTERCOM_APP_ID: process.env.HADRON_METRICS_INTERCOM_APP_ID, HADRON_PRODUCT_NAME: process.env.HADRON_PRODUCT_NAME, @@ -43,15 +77,12 @@ describe('setupIntercom', function () { NODE_ENV: process.env.NODE_ENV, }; + process.env.HADRON_AUTO_UPDATE_ENDPOINT = FAKE_HADRON_AUTO_UPDATE_ENDPOINT; process.env.HADRON_PRODUCT_NAME = 'My App Name' as any; process.env.HADRON_APP_VERSION = 'v0.0.0-test.123'; process.env.NODE_ENV = 'test'; process.env.HADRON_METRICS_INTERCOM_APP_ID = 'appid123'; - fetchMock = sinon.stub(); - window.fetch = fetchMock; - // NOTE: we use 301 since intercom will redirects - // to the actual location of the widget script - fetchMock.resolves({ status: 301 } as Response); + fetchMock = sinon.stub(globalThis, 'fetch'); preferences = await createSandboxFromDefaultPreferences(); await preferences.savePreferences({ enableFeedbackPanel: true, @@ -61,16 +92,23 @@ describe('setupIntercom', function () { }); afterEach(function () { + process.env.HADRON_AUTO_UPDATE_ENDPOINT = + backupEnv.HADRON_AUTO_UPDATE_ENDPOINT; process.env.HADRON_METRICS_INTERCOM_APP_ID = backupEnv.HADRON_METRICS_INTERCOM_APP_ID; process.env.HADRON_PRODUCT_NAME = backupEnv.HADRON_PRODUCT_NAME as any; process.env.HADRON_APP_VERSION = backupEnv.HADRON_APP_VERSION as any; process.env.NODE_ENV = backupEnv.NODE_ENV; - fetchMock.reset(); + fetchMock.restore(); + resetIntercomAllowedCache(); }); describe('when it can be enabled', function () { it('calls intercomScript.load when feedback gets enabled and intercomScript.unload when feedback gets disabled', async function () { + fetchMock.callsFake( + createMockFetch({ integrations: { intercom: true } }) + ); + await preferences.savePreferences({ enableFeedbackPanel: true, }); @@ -100,6 +138,19 @@ describe('setupIntercom', function () { expect(intercomScript.load).not.to.have.been.called; expect(intercomScript.unload).to.have.been.called; }); + + it('calls intercomScript.unload when the update server disables the integration', async function () { + fetchMock.callsFake( + createMockFetch({ integrations: { intercom: false } }) + ); + + await preferences.savePreferences({ + enableFeedbackPanel: true, + }); + const { intercomScript } = await testRunSetupIntercom(); + expect(intercomScript.load).not.to.have.been.called; + expect(intercomScript.unload).to.have.been.called; + }); }); describe('when cannot be enabled', function () { diff --git a/packages/compass-intercom/src/setup-intercom.ts b/packages/compass-intercom/src/setup-intercom.ts index ddc78a213d1..7d537b4fa93 100644 --- a/packages/compass-intercom/src/setup-intercom.ts +++ b/packages/compass-intercom/src/setup-intercom.ts @@ -36,14 +36,26 @@ export async function setupIntercom( app_stage: process.env.NODE_ENV, }; - if (enableFeedbackPanel) { + async function toggleEnableFeedbackPanel(enableFeedbackPanel: boolean) { + if (enableFeedbackPanel && (await isIntercomAllowed())) { + debug('loading intercom script'); + intercomScript.load(metadata); + } else { + debug('unloading intercom script'); + intercomScript.unload(); + } + } + + const shouldLoad = enableFeedbackPanel && (await isIntercomAllowed()); + + if (shouldLoad) { // In some environment the network can be firewalled, this is a safeguard to avoid // uncaught errors when injecting the script. debug('testing intercom availability'); const intercomWidgetUrl = buildIntercomScriptUrl(metadata.app_id); - const response = await window.fetch(intercomWidgetUrl).catch((e) => { + const response = await fetch(intercomWidgetUrl).catch((e) => { debug('fetch failed', e); return null; }); @@ -56,27 +68,82 @@ export async function setupIntercom( debug('intercom is reachable, proceeding with the setup'); } else { debug( - 'not testing intercom connectivity because enableFeedbackPanel == false' + 'not testing intercom connectivity because enableFeedbackPanel == false || isAllowed == false' ); } - const toggleEnableFeedbackPanel = (enableFeedbackPanel: boolean) => { - if (enableFeedbackPanel) { - debug('loading intercom script'); - intercomScript.load(metadata); - } else { - debug('unloading intercom script'); - intercomScript.unload(); - } - }; - - toggleEnableFeedbackPanel(!!enableFeedbackPanel); + try { + await toggleEnableFeedbackPanel(shouldLoad); + } catch (error) { + debug('initial toggle failed', { + error, + }); + } preferences.onPreferenceValueChanged( 'enableFeedbackPanel', (enableFeedbackPanel) => { debug('enableFeedbackPanel changed'); - toggleEnableFeedbackPanel(enableFeedbackPanel); + void toggleEnableFeedbackPanel(enableFeedbackPanel); } ); } + +let isIntercomAllowedPromise: Promise | null = null; + +function isIntercomAllowed(): Promise { + if (!isIntercomAllowedPromise) { + isIntercomAllowedPromise = fetchIntegrations().then( + ({ intercom }) => intercom, + (error) => { + debug( + 'Failed to fetch intercom integration status, defaulting to false', + { error } + ); + return false; + } + ); + } + return isIntercomAllowedPromise; +} + +export function resetIntercomAllowedCache(): void { + isIntercomAllowedPromise = null; +} + +/** + * TODO: Move this to a shared package if we start using it to toggle other integrations. + */ +function getAutoUpdateEndpoint() { + const { HADRON_AUTO_UPDATE_ENDPOINT, HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE } = + process.env; + const result = + HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE || HADRON_AUTO_UPDATE_ENDPOINT; + if (!result) { + throw new Error( + 'Expected HADRON_AUTO_UPDATE_ENDPOINT or HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE to be set' + ); + } + return result; +} + +/** + * Fetches the integrations configuration from the update server. + * TODO: Move this to a shared package if we start using it to toggle other integrations. + */ +async function fetchIntegrations(): Promise<{ intercom: boolean }> { + const url = `${getAutoUpdateEndpoint()}/api/v2/integrations`; + debug('requesting integrations status', { url }); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Expected an OK response, got ${response.status} '${response.statusText}'` + ); + } + const result = await response.json(); + debug('got integrations response', { result }); + if (typeof result.intercom !== 'boolean') { + throw new Error(`Expected 'intercom' to be a boolean`); + } + return result; +} diff --git a/packages/compass/src/app/utils/csp.ts b/packages/compass/src/app/utils/csp.ts index 7678c5a52b7..939d81991a3 100644 --- a/packages/compass/src/app/utils/csp.ts +++ b/packages/compass/src/app/utils/csp.ts @@ -89,7 +89,7 @@ export function injectCSP() { extraAllowed.push('ws://localhost:*'); // Used by proxy tests, since Chrome does not like proxying localhost // (this does not result in actual outgoing HTTP requests) - extraAllowed.push('http://compass.mongodb.com/'); + extraAllowed.push('http://proxy-test-compass.mongodb.com/'); } const cspContent = Object.entries(defaultCSP)