Skip to content
12 changes: 8 additions & 4 deletions packages/compass-e2e-tests/tests/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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 () {
Expand Down
64 changes: 57 additions & 7 deletions packages/compass-intercom/src/setup-intercom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;
}): 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),
Expand All @@ -19,7 +49,10 @@ const mockUser: User = {

describe('setupIntercom', function () {
let backupEnv: Partial<typeof process.env>;
let fetchMock: SinonStub;
let fetchMock: SinonStub<
Parameters<typeof globalThis.fetch>,
ReturnType<typeof globalThis.fetch>
>;
let preferences: PreferencesAccess;

async function testRunSetupIntercom() {
Expand All @@ -36,22 +69,20 @@ 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,
HADRON_APP_VERSION: process.env.HADRON_APP_VERSION,
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');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this makes it possible to restore afterwards.

preferences = await createSandboxFromDefaultPreferences();
await preferences.savePreferences({
enableFeedbackPanel: true,
Expand All @@ -61,16 +92,22 @@ 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();
});

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,
});
Expand Down Expand Up @@ -100,6 +137,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 () {
Expand Down
88 changes: 73 additions & 15 deletions packages/compass-intercom/src/setup-intercom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -56,27 +68,73 @@ 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have a good suggestion from the top of my head, but I did find it a bit mind bending that shouldLoad here already takes awaited isIntercomAllowed into account, but then toggleEnableFeedbackPanel immediately fetches it again. I understand the purpose of this, but kinda wish this was maybe somehow less tangled. I don't think it's that big of a deal, but wanted to highlight

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a valid concern and I had the response cached in a previous commit. I reverted it because it felt a bit premature, but thinking more about it, I do think it makes sense - this is going to be fetched by every render process and we might as well do it once instead of twice.

} catch (error) {
debug('initial toggle failed', {
error,
});
}

preferences.onPreferenceValueChanged(
'enableFeedbackPanel',
(enableFeedbackPanel) => {
debug('enableFeedbackPanel changed');
toggleEnableFeedbackPanel(enableFeedbackPanel);
void toggleEnableFeedbackPanel(enableFeedbackPanel);
}
);
}

function isIntercomAllowed(): Promise<boolean> {
return fetchIntegrations().then(
({ intercom }) => intercom,
(error) => {
debug(
'Failed to fetch intercom integration status, defaulting to false',
{ error }
);
return false;
}
);
}

/**
* 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;
}
2 changes: 1 addition & 1 deletion packages/compass/src/app/utils/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm changing this to avoid conflicts with the actual compass update server URL called when setting up intercom.

}
const cspContent =
Object.entries(defaultCSP)
Expand Down