Commit b2df9cd7 authored by Robert Knight's avatar Robert Knight

Add module for fetching config from ancestor frames

Implement `fetchConfig` function which fetches config from the host page
/ embedding web app and merges it with config rendered into the sidebar
HTML page.

This function supports a new method of fetching config from the embedder
by performing a "requestSidebarConfig" RPC call to the origin set with
the "requestSidebarConfigFromFrameWithOrigin" setting. That setting must
itself by set in the page where the embed.js script is loaded.

Also extract the `assertPromiseIsRejected` helper into `promise-util.js`
for re-use and add a note about needing to return the result.
parent cce26b64
'use strict'; 'use strict';
/**
* Helper to assert a promise is rejected.
*
* IMPORTANT NOTE: If you use this you must _return_ the result of this function
* from your test, otherwise the test runner will not know when your test is
* finished.
*
* @param {Promise} promise
* @param {string} expectedErr - Expected `message` property of error
*/
function assertPromiseIsRejected(promise, expectedErr) {
const rejectFlag = {};
return promise.catch(err => {
assert.equal(err.message, expectedErr);
return rejectFlag;
}).then(result => {
assert.equal(result, rejectFlag, 'expected promise to be rejected but it was fulfilled');
});
}
/** /**
* Takes a Promise<T> and returns a Promise<Result> * Takes a Promise<T> and returns a Promise<Result>
* where Result = { result: T } | { error: any }. * where Result = { result: T } | { error: any }.
* *
* This is useful for testing that promises are rejected * This is useful for testing that promises are rejected
* as expected in tests. * as expected in tests.
*
* Consider using `assertPromiseIsRejected` instead.
*/ */
function toResult(promise) { function toResult(promise) {
return promise.then(function (result) { return promise.then(function (result) {
...@@ -16,5 +39,6 @@ function toResult(promise) { ...@@ -16,5 +39,6 @@ function toResult(promise) {
} }
module.exports = { module.exports = {
toResult: toResult, assertPromiseIsRejected,
toResult,
}; };
'use strict';
const getApiUrl = require('../get-api-url');
const hostConfig = require('../host-config');
const postMessageJsonRpc = require('./postmessage-json-rpc');
function ancestors(window_) {
if (window_ === window_.top) {
return [];
}
// nb. The top window's `parent` is itself!
const ancestors = [];
do {
window_ = window_.parent;
ancestors.push(window_);
} while (window_ !== window_.top);
return ancestors;
}
/**
* Fetch client configuration from an ancestor frame.
*
* @param {string} origin - The origin of the frame to fetch config from.
* @param {Window} window_ - Test seam.
* @return {Promise<any>}
*/
function fetchConfigFromAncestorFrame(origin, window_=window) {
const configResponses = [];
for (let ancestor of ancestors(window_)) {
const timeout = 3000;
const result = postMessageJsonRpc.call(
ancestor, origin, 'requestConfig', timeout
);
configResponses.push(result);
}
if (configResponses.length === 0) {
configResponses.push(Promise.reject(new Error('Client is top frame')));
}
return Promise.race(configResponses);
}
/**
* Merge client configuration from h service with config fetched from
* embedding frame.
*
* Typically the configuration from the embedding frame is passed
* synchronously in the query string. However it can also be retrieved from
* an ancestor of the embedding frame. See tests for more details.
*
* @param {Object} appConfig - Settings rendered into `app.html` by the h service.
* @param {Window} window_ - Test seam.
* @return {Promise<Object>} - The merged settings.
*/
function fetchConfig(appConfig, window_=window) {
const hostPageConfig = hostConfig(window_);
let embedderConfig;
if (hostPageConfig.requestConfigFromFrame) {
const origin = hostPageConfig.requestConfigFromFrame;
embedderConfig = fetchConfigFromAncestorFrame(origin, window_);
} else {
embedderConfig = Promise.resolve(hostPageConfig);
}
return embedderConfig.then(embedderConfig => {
const mergedConfig = Object.assign({}, appConfig, embedderConfig);
mergedConfig.apiUrl = getApiUrl(mergedConfig);
return mergedConfig;
});
}
module.exports = {
fetchConfig,
};
'use strict';
const proxyquire = require('proxyquire');
const { assertPromiseIsRejected } = require('../../../shared/test/promise-util');
describe('sidebar.util.fetch-config', () => {
let fetchConfig;
let fakeHostConfig;
let fakeJsonRpc;
let fakeWindow;
beforeEach(() => {
fakeHostConfig = sinon.stub();
fakeJsonRpc = {
call: sinon.stub(),
};
const patched = proxyquire('../fetch-config', {
'../host-config': fakeHostConfig,
'./postmessage-json-rpc': fakeJsonRpc,
});
fetchConfig = patched.fetchConfig;
// By default, embedder provides no custom config.
fakeHostConfig.returns({});
// By default, fetching config from parent frames fails.
fakeJsonRpc.call.throws(new Error('call() response not set'));
// Setup fake window hierarchy.
const fakeTopWindow = { parent: null, top: null };
fakeTopWindow.parent = fakeTopWindow; // Yep, the DOM really works like this.
fakeTopWindow.top = fakeTopWindow;
const fakeParent = { parent: fakeTopWindow, top: fakeTopWindow };
fakeWindow = { parent: fakeParent, top: fakeTopWindow };
});
describe('fetchConfig', () => {
// By default, combine the settings rendered into the sidebar's HTML page
// by h with the settings from `window.hypothesisConfig` in the parent
// window.
it('reads config from sidebar URL query string', () => {
fakeHostConfig
.withArgs(fakeWindow)
.returns({ apiUrl: 'https://dev.hypothes.is/api/' });
return fetchConfig({}, fakeWindow).then(config => {
assert.deepEqual(config, { apiUrl: 'https://dev.hypothes.is/api/' });
});
});
it('merges config from sidebar HTML app and embedder', () => {
const apiUrl = 'https://dev.hypothes.is/api/';
fakeHostConfig.returns({
appType: 'via',
});
return fetchConfig({ apiUrl }, fakeWindow).then(config => {
assert.deepEqual(config, { apiUrl, appType: 'via' });
});
});
// By default, don't try to fetch settings from parent frames via
// `postMessage` requests.
it('does not fetch settings from ancestor frames by default', () => {
return fetchConfig({}, fakeWindow).then(() => {
assert.notCalled(fakeJsonRpc.call);
});
});
// In scenarios like LMS integrations, the client is annotating a document
// inside an iframe and the client needs to retrieve configuration securely
// from the top-level window without that configuration being exposed to the
// document itself.
//
// This config fetching is enabled by a setting in the host page.
context('when fetching config from an ancestor frame is enabled', () => {
const expectedTimeout = 3000;
beforeEach(() => {
fakeHostConfig.returns({
requestConfigFromFrame: 'https://embedder.com',
});
sinon.stub(console, 'warn');
});
afterEach(() => {
console.warn.restore();
});
it('fetches config from ancestor frames', () => {
fakeJsonRpc.call.returns(Promise.resolve({}));
return fetchConfig({}, fakeWindow).then(() => {
// The client will send a message to each ancestor asking for
// configuration. Only those with the expected origin will be able to
// respond.
const ancestors = [fakeWindow.parent, fakeWindow.parent.parent];
ancestors.forEach(frame => {
assert.calledWith(
fakeJsonRpc.call, frame, 'https://embedder.com', 'requestConfig', expectedTimeout
);
});
});
});
it('rejects if sidebar is top frame', () => {
fakeWindow.parent = fakeWindow;
fakeWindow.top = fakeWindow;
const config = fetchConfig({}, fakeWindow);
return assertPromiseIsRejected(config, 'Client is top frame');
});
it('rejects if fetching config fails', () => {
fakeJsonRpc.call.returns(Promise.reject(new Error('Nope')));
const config = fetchConfig({}, fakeWindow);
return assertPromiseIsRejected(config, 'Nope');
});
it('returns config from ancestor frame', () => {
// When the embedder responds with configuration, that should be
// returned by `fetchConfig`.
fakeJsonRpc.call.returns(new Promise(() => {}));
fakeJsonRpc.call.withArgs(
fakeWindow.parent.parent, 'https://embedder.com', 'requestConfig', expectedTimeout
).returns(Promise.resolve({
// Here the embedder's parent returns service configuration
// (aka. credentials for automatic login).
services: [{
apiUrl: 'https://servi.ce/api/',
grantToken: 'secret-token',
}],
}));
return fetchConfig({}, fakeWindow).then(config => {
assert.deepEqual(config, {
apiUrl: 'https://servi.ce/api/',
services: [{
apiUrl: 'https://servi.ce/api/',
grantToken: 'secret-token',
}],
});
});
});
});
});
});
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
const EventEmitter = require('tiny-emitter'); const EventEmitter = require('tiny-emitter');
const { assertPromiseIsRejected } = require('../../../shared/test/promise-util');
const { call } = require('../postmessage-json-rpc'); const { call } = require('../postmessage-json-rpc');
class FakeWindow { class FakeWindow {
...@@ -12,16 +13,6 @@ class FakeWindow { ...@@ -12,16 +13,6 @@ class FakeWindow {
} }
} }
function assertPromiseIsRejected(promise, expectedErr) {
const rejectFlag = {};
return promise.catch(err => {
assert.equal(err.message, expectedErr);
return rejectFlag;
}).then(result => {
assert.equal(result, rejectFlag, 'expected promise to be rejected but it was fulfilled');
});
}
describe('sidebar.util.postmessage-json-rpc', () => { describe('sidebar.util.postmessage-json-rpc', () => {
const origin = 'https://embedder.com'; const origin = 'https://embedder.com';
const messageId = 42; const messageId = 42;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment