Skip to content

Commit d5cce0d

Browse files
authored
chore(intercom): Add request to the update server to determine if Intercom integration is allowed COMPASS-9371 (#6982)
* Add request to the update server to determine if Intercom integration is allowed * Turn toggleEnableFeedbackPanel async and check isIntercomAllowed * Remove premature optimization of memoizing integrations response * Restore fetch mock correctly * Add debug calls * Fix tests * Fix lint * Use a different domain in proxy tests * Incorporated feedback * Cache response from update server
1 parent e709f7b commit d5cce0d

File tree

4 files changed

+150
-28
lines changed

4 files changed

+150
-28
lines changed

packages/compass-e2e-tests/tests/proxy.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ describe('Proxy support', function () {
6565
browser = compass.browser;
6666

6767
const result = await browser.execute(async function () {
68-
const response = await fetch('http://compass.mongodb.com/');
68+
const response = await fetch('http://proxy-test-compass.mongodb.com/');
6969
return await response.text();
7070
});
71-
expect(result).to.equal('hello, http://compass.mongodb.com/ (proxy1)');
71+
expect(result).to.equal(
72+
'hello, http://proxy-test-compass.mongodb.com/ (proxy1)'
73+
);
7274
});
7375

7476
it('can change the proxy option dynamically', async function () {
@@ -80,10 +82,12 @@ describe('Proxy support', function () {
8082
`http://localhost:${port(httpProxyServer2)}`
8183
);
8284
const result = await browser.execute(async function () {
83-
const response = await fetch('http://compass.mongodb.com/');
85+
const response = await fetch('http://proxy-test-compass.mongodb.com/');
8486
return await response.text();
8587
});
86-
expect(result).to.equal('hello, http://compass.mongodb.com/ (proxy2)');
88+
expect(result).to.equal(
89+
'hello, http://proxy-test-compass.mongodb.com/ (proxy2)'
90+
);
8791
});
8892

8993
context('when connecting to a cluster', function () {

packages/compass-intercom/src/setup-intercom.spec.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { SinonStub } from 'sinon';
33
import sinon from 'sinon';
44

5-
import { setupIntercom } from './setup-intercom';
5+
import { setupIntercom, resetIntercomAllowedCache } from './setup-intercom';
66
import { expect } from 'chai';
77
import type { IntercomScript } from './intercom-script';
88
import type { PreferencesAccess } from 'compass-preferences-model';
@@ -11,6 +11,36 @@ import {
1111
type User,
1212
} from 'compass-preferences-model';
1313

14+
// Picking something which won't be blocked by CORS
15+
const FAKE_HADRON_AUTO_UPDATE_ENDPOINT = 'https://compass.mongodb.com';
16+
17+
function createMockFetch({
18+
integrations,
19+
}: {
20+
integrations: Record<string, boolean>;
21+
}): typeof globalThis.fetch {
22+
return (url) => {
23+
if (typeof url !== 'string') {
24+
throw new Error('Expected url to be a string');
25+
}
26+
if (url.startsWith(FAKE_HADRON_AUTO_UPDATE_ENDPOINT)) {
27+
if (url === `${FAKE_HADRON_AUTO_UPDATE_ENDPOINT}/api/v2/integrations`) {
28+
return Promise.resolve({
29+
ok: true,
30+
json() {
31+
return Promise.resolve(integrations);
32+
},
33+
} as Response);
34+
}
35+
} else if (url === 'https://widget.intercom.io/widget/appid123') {
36+
// NOTE: we use 301 since intercom will redirects
37+
// to the actual location of the widget script
38+
return Promise.resolve({ status: 301 } as Response);
39+
}
40+
throw new Error(`Unexpected URL called on the fake update server: ${url}`);
41+
};
42+
}
43+
1444
const mockUser: User = {
1545
id: 'user-123',
1646
createdAt: new Date(1649432549945),
@@ -19,7 +49,10 @@ const mockUser: User = {
1949

2050
describe('setupIntercom', function () {
2151
let backupEnv: Partial<typeof process.env>;
22-
let fetchMock: SinonStub;
52+
let fetchMock: SinonStub<
53+
Parameters<typeof globalThis.fetch>,
54+
ReturnType<typeof globalThis.fetch>
55+
>;
2356
let preferences: PreferencesAccess;
2457

2558
async function testRunSetupIntercom() {
@@ -36,22 +69,20 @@ describe('setupIntercom', function () {
3669

3770
beforeEach(async function () {
3871
backupEnv = {
72+
HADRON_AUTO_UPDATE_ENDPOINT: process.env.HADRON_AUTO_UPDATE_ENDPOINT,
3973
HADRON_METRICS_INTERCOM_APP_ID:
4074
process.env.HADRON_METRICS_INTERCOM_APP_ID,
4175
HADRON_PRODUCT_NAME: process.env.HADRON_PRODUCT_NAME,
4276
HADRON_APP_VERSION: process.env.HADRON_APP_VERSION,
4377
NODE_ENV: process.env.NODE_ENV,
4478
};
4579

80+
process.env.HADRON_AUTO_UPDATE_ENDPOINT = FAKE_HADRON_AUTO_UPDATE_ENDPOINT;
4681
process.env.HADRON_PRODUCT_NAME = 'My App Name' as any;
4782
process.env.HADRON_APP_VERSION = 'v0.0.0-test.123';
4883
process.env.NODE_ENV = 'test';
4984
process.env.HADRON_METRICS_INTERCOM_APP_ID = 'appid123';
50-
fetchMock = sinon.stub();
51-
window.fetch = fetchMock;
52-
// NOTE: we use 301 since intercom will redirects
53-
// to the actual location of the widget script
54-
fetchMock.resolves({ status: 301 } as Response);
85+
fetchMock = sinon.stub(globalThis, 'fetch');
5586
preferences = await createSandboxFromDefaultPreferences();
5687
await preferences.savePreferences({
5788
enableFeedbackPanel: true,
@@ -61,16 +92,23 @@ describe('setupIntercom', function () {
6192
});
6293

6394
afterEach(function () {
95+
process.env.HADRON_AUTO_UPDATE_ENDPOINT =
96+
backupEnv.HADRON_AUTO_UPDATE_ENDPOINT;
6497
process.env.HADRON_METRICS_INTERCOM_APP_ID =
6598
backupEnv.HADRON_METRICS_INTERCOM_APP_ID;
6699
process.env.HADRON_PRODUCT_NAME = backupEnv.HADRON_PRODUCT_NAME as any;
67100
process.env.HADRON_APP_VERSION = backupEnv.HADRON_APP_VERSION as any;
68101
process.env.NODE_ENV = backupEnv.NODE_ENV;
69-
fetchMock.reset();
102+
fetchMock.restore();
103+
resetIntercomAllowedCache();
70104
});
71105

72106
describe('when it can be enabled', function () {
73107
it('calls intercomScript.load when feedback gets enabled and intercomScript.unload when feedback gets disabled', async function () {
108+
fetchMock.callsFake(
109+
createMockFetch({ integrations: { intercom: true } })
110+
);
111+
74112
await preferences.savePreferences({
75113
enableFeedbackPanel: true,
76114
});
@@ -100,6 +138,19 @@ describe('setupIntercom', function () {
100138
expect(intercomScript.load).not.to.have.been.called;
101139
expect(intercomScript.unload).to.have.been.called;
102140
});
141+
142+
it('calls intercomScript.unload when the update server disables the integration', async function () {
143+
fetchMock.callsFake(
144+
createMockFetch({ integrations: { intercom: false } })
145+
);
146+
147+
await preferences.savePreferences({
148+
enableFeedbackPanel: true,
149+
});
150+
const { intercomScript } = await testRunSetupIntercom();
151+
expect(intercomScript.load).not.to.have.been.called;
152+
expect(intercomScript.unload).to.have.been.called;
153+
});
103154
});
104155

105156
describe('when cannot be enabled', function () {

packages/compass-intercom/src/setup-intercom.ts

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,26 @@ export async function setupIntercom(
3636
app_stage: process.env.NODE_ENV,
3737
};
3838

39-
if (enableFeedbackPanel) {
39+
async function toggleEnableFeedbackPanel(enableFeedbackPanel: boolean) {
40+
if (enableFeedbackPanel && (await isIntercomAllowed())) {
41+
debug('loading intercom script');
42+
intercomScript.load(metadata);
43+
} else {
44+
debug('unloading intercom script');
45+
intercomScript.unload();
46+
}
47+
}
48+
49+
const shouldLoad = enableFeedbackPanel && (await isIntercomAllowed());
50+
51+
if (shouldLoad) {
4052
// In some environment the network can be firewalled, this is a safeguard to avoid
4153
// uncaught errors when injecting the script.
4254
debug('testing intercom availability');
4355

4456
const intercomWidgetUrl = buildIntercomScriptUrl(metadata.app_id);
4557

46-
const response = await window.fetch(intercomWidgetUrl).catch((e) => {
58+
const response = await fetch(intercomWidgetUrl).catch((e) => {
4759
debug('fetch failed', e);
4860
return null;
4961
});
@@ -56,27 +68,82 @@ export async function setupIntercom(
5668
debug('intercom is reachable, proceeding with the setup');
5769
} else {
5870
debug(
59-
'not testing intercom connectivity because enableFeedbackPanel == false'
71+
'not testing intercom connectivity because enableFeedbackPanel == false || isAllowed == false'
6072
);
6173
}
6274

63-
const toggleEnableFeedbackPanel = (enableFeedbackPanel: boolean) => {
64-
if (enableFeedbackPanel) {
65-
debug('loading intercom script');
66-
intercomScript.load(metadata);
67-
} else {
68-
debug('unloading intercom script');
69-
intercomScript.unload();
70-
}
71-
};
72-
73-
toggleEnableFeedbackPanel(!!enableFeedbackPanel);
75+
try {
76+
await toggleEnableFeedbackPanel(shouldLoad);
77+
} catch (error) {
78+
debug('initial toggle failed', {
79+
error,
80+
});
81+
}
7482

7583
preferences.onPreferenceValueChanged(
7684
'enableFeedbackPanel',
7785
(enableFeedbackPanel) => {
7886
debug('enableFeedbackPanel changed');
79-
toggleEnableFeedbackPanel(enableFeedbackPanel);
87+
void toggleEnableFeedbackPanel(enableFeedbackPanel);
8088
}
8189
);
8290
}
91+
92+
let isIntercomAllowedPromise: Promise<boolean> | null = null;
93+
94+
function isIntercomAllowed(): Promise<boolean> {
95+
if (!isIntercomAllowedPromise) {
96+
isIntercomAllowedPromise = fetchIntegrations().then(
97+
({ intercom }) => intercom,
98+
(error) => {
99+
debug(
100+
'Failed to fetch intercom integration status, defaulting to false',
101+
{ error }
102+
);
103+
return false;
104+
}
105+
);
106+
}
107+
return isIntercomAllowedPromise;
108+
}
109+
110+
export function resetIntercomAllowedCache(): void {
111+
isIntercomAllowedPromise = null;
112+
}
113+
114+
/**
115+
* TODO: Move this to a shared package if we start using it to toggle other integrations.
116+
*/
117+
function getAutoUpdateEndpoint() {
118+
const { HADRON_AUTO_UPDATE_ENDPOINT, HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE } =
119+
process.env;
120+
const result =
121+
HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE || HADRON_AUTO_UPDATE_ENDPOINT;
122+
if (!result) {
123+
throw new Error(
124+
'Expected HADRON_AUTO_UPDATE_ENDPOINT or HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE to be set'
125+
);
126+
}
127+
return result;
128+
}
129+
130+
/**
131+
* Fetches the integrations configuration from the update server.
132+
* TODO: Move this to a shared package if we start using it to toggle other integrations.
133+
*/
134+
async function fetchIntegrations(): Promise<{ intercom: boolean }> {
135+
const url = `${getAutoUpdateEndpoint()}/api/v2/integrations`;
136+
debug('requesting integrations status', { url });
137+
const response = await fetch(url);
138+
if (!response.ok) {
139+
throw new Error(
140+
`Expected an OK response, got ${response.status} '${response.statusText}'`
141+
);
142+
}
143+
const result = await response.json();
144+
debug('got integrations response', { result });
145+
if (typeof result.intercom !== 'boolean') {
146+
throw new Error(`Expected 'intercom' to be a boolean`);
147+
}
148+
return result;
149+
}

packages/compass/src/app/utils/csp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function injectCSP() {
8989
extraAllowed.push('ws://localhost:*');
9090
// Used by proxy tests, since Chrome does not like proxying localhost
9191
// (this does not result in actual outgoing HTTP requests)
92-
extraAllowed.push('http://compass.mongodb.com/');
92+
extraAllowed.push('http://proxy-test-compass.mongodb.com/');
9393
}
9494
const cspContent =
9595
Object.entries(defaultCSP)

0 commit comments

Comments
 (0)