Commit aae34baf authored by Kyle Keating's avatar Kyle Keating

Add preStartServer() method to cross-origin-rcp module

When called, allows any incoming rpc request to wait in a queue and be proceeded a short time later when the actual start() method is called. This prevents potential timeouts from incoming RPC requests from LMS (or similar) that arrive after we tipped off the parent frame with a fetchConfig outgoing request but before startServer() is called.

- Rename start() to startServer()
- Use newer module export standard that matches the rest of the app
parent b4953ce6
import warnOnce from '../shared/warn-once';
// Array to keep track of pre-start requests
let preStartQueue = [];
/**
* Return the mapped methods that can be called remotely via this server.
*
......@@ -48,7 +51,18 @@ function isJsonRpcMessage(data) {
function start(store, settings, $window) {
const methods = registeredMethods(store);
$window.addEventListener('message', function receiveMessage(event) {
// Process the pre-start incoming RPC requests
preStartQueue.forEach(event => {
receiveMessage(event);
});
// Clear the queue and remove the preStartMessageListener
preStartQueue = [];
window.removeEventListener('message', preStartMessageListener);
// Start listening to new RPC requests
$window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
let allowedOrigins = settings.rpcAllowedOrigins || [];
if (!isJsonRpcMessage(event.data)) {
......@@ -67,7 +81,7 @@ function start(store, settings, $window) {
let jsonRpcRequest = event.data;
event.source.postMessage(jsonRpcResponse(jsonRpcRequest), event.origin);
});
}
/** Return a JSON-RPC response to the given JSON-RPC request object. */
function jsonRpcResponse(request) {
......@@ -93,8 +107,32 @@ function start(store, settings, $window) {
}
}
export default {
server: {
start: start,
},
/**
* Queues all incoming RPC requests so they can be handled later.
*
* @param {MessageEvent} event - RPC event payload
*/
function preStartMessageListener(event) {
preStartQueue.push(event);
}
/**
* Start listening to incoming RPC requests right away so they don't timeout. These
* requests are saved until the server starts up and then they can be handled accordingly.
*
* Why?
*
* This allows the client to fetch its config from the parent app frame and potentially
* receive new unsolicited incoming requests that the parent app may send before this
* RPC server starts.
*
* @param {Window} window_ - Test seam
*/
function preStart(window_ = window) {
window_.addEventListener('message', preStartMessageListener);
}
module.exports = {
preStartServer: preStart,
startServer: start,
};
......@@ -2,7 +2,10 @@
import { jsonConfigsFrom } from '../shared/settings';
import crossOriginRPC from './cross-origin-rpc.js';
import {
startServer as startRPCServer,
preStartServer as preStartRPCServer,
} from './cross-origin-rpc.js';
import addAnalytics from './ga';
import disableOpenerForExternalLinks from './util/disable-opener-for-external-links';
import { fetchConfig } from './util/fetch-config';
......@@ -294,7 +297,7 @@ function startAngularApp(config) {
.run(sendPageView)
.run(setupApi)
.run(setupRoute)
.run(crossOriginRPC.server.start);
.run(startRPCServer);
// Work around a check in Angular's $sniffer service that causes it to
// incorrectly determine that Firefox extensions are Chrome Packaged Apps which
......@@ -311,6 +314,9 @@ function startAngularApp(config) {
angular.bootstrap(appEl, ['h'], { strictDi: true });
}
// Start capturing RPC requests before we start the RPC server (startRPCServer)
preStartRPCServer();
fetchConfig(appConfig)
.then(config => {
startAngularApp(config);
......
import crossOriginRPC from '../cross-origin-rpc';
import { $imports } from '../cross-origin-rpc';
import EventEmitter from 'tiny-emitter';
describe('crossOriginRPC', function() {
describe('server', function() {
let addedListener; // The postMessage() listener that the server adds.
import { startServer, preStartServer, $imports } from '../cross-origin-rpc.js';
class FakeWindow {
constructor() {
this.emitter = new EventEmitter();
this.addEventListener = this.emitter.on.bind(this.emitter);
this.removeEventListener = this.emitter.off.bind(this.emitter);
}
}
describe('sidebar/cross-origin-rcp', function() {
let fakeStore;
let fakeWarnOnce;
let fakeWindow;
let settings;
let source;
let frame;
beforeEach(function() {
fakeStore = {
changeFocusModeUser: sinon.stub(),
};
fakeWindow = {
addEventListener: sinon.stub().callsFake(function(type, listener) {
// Save the registered listener function in a variable so test code
// can access it later.
addedListener = listener;
}),
};
frame = { postMessage: sinon.stub() };
fakeWindow = new FakeWindow();
settings = {
rpcAllowedOrigins: ['https://allowed1.com', 'https://allowed2.com'],
};
source = { postMessage: sinon.stub() };
fakeWarnOnce = sinon.stub();
$imports.$mock({
......@@ -40,35 +40,20 @@ describe('crossOriginRPC', function() {
$imports.$restore();
});
/**
* Directly call the postMessage() listener func that the server
* registered. This simulates what would happen if window.postMessage()
* were called.
*/
function postMessage(event) {
addedListener(event);
}
it('adds a postMessage() event listener function', function() {
crossOriginRPC.server.start(fakeStore, {}, fakeWindow);
assert.isTrue(fakeWindow.addEventListener.calledOnce);
assert.isTrue(fakeWindow.addEventListener.calledWith('message'));
});
describe('#startServer', function() {
it('sends a response with the "ok" result', function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
data: { jsonrpc: '2.0', method: 'changeFocusModeUser', id: 42 },
origin: 'https://allowed1.com',
source: source,
source: frame,
});
assert.isTrue(source.postMessage.calledOnce);
assert.isTrue(frame.postMessage.calledOnce);
assert.isTrue(
source.postMessage.calledWithExactly(
frame.postMessage.calledWithExactly(
{
jsonrpc: '2.0',
id: 42,
......@@ -80,9 +65,9 @@ describe('crossOriginRPC', function() {
});
it('calls the registered method with the provided params', function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
data: {
jsonrpc: '2.0',
method: 'changeFocusModeUser',
......@@ -90,38 +75,39 @@ describe('crossOriginRPC', function() {
params: ['one', 'two'],
},
origin: 'https://allowed1.com',
source: source,
source: frame,
});
assert.isTrue(
fakeStore.changeFocusModeUser.calledWithExactly('one', 'two')
);
});
it('calls the registered method with no params', function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
data: {
jsonrpc: '2.0',
method: 'changeFocusModeUser',
id: 42,
},
origin: 'https://allowed1.com',
source: source,
source: frame,
});
assert.isTrue(fakeStore.changeFocusModeUser.calledWithExactly());
});
it('does not call the unregistered method', function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
data: {
method: 'unregisteredMethod',
id: 42,
},
origin: 'https://allowed1.com',
source: source,
source: frame,
});
assert.isTrue(fakeStore.changeFocusModeUser.notCalled);
});
......@@ -129,12 +115,16 @@ describe('crossOriginRPC', function() {
[{}, null, { jsonrpc: '1.0' }].forEach(invalidMessage => {
it('ignores non JSON-RPC messages', () => {
const settings = { rpcAllowedOrigins: [] };
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({ data: invalidMessage, origin: 'https://foo.com' });
fakeWindow.emitter.emit('message', {
data: invalidMessage,
origin: 'https://foo.com',
source: frame,
});
assert.notCalled(fakeWarnOnce);
assert.isTrue(source.postMessage.notCalled);
assert.isTrue(frame.postMessage.notCalled);
});
});
......@@ -144,35 +134,35 @@ describe('crossOriginRPC', function() {
{ rpcAllowedOrigins: ['https://allowed1.com', 'https://allowed2.com'] },
].forEach(function(settings) {
it("doesn't respond if the origin isn't allowed", function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
origin: 'https://notallowed.com',
data: { jsonrpc: '2.0', method: 'changeFocusModeUser', id: 42 },
source: source,
source: frame,
});
assert.calledWith(
fakeWarnOnce,
sinon.match(/Ignoring JSON-RPC request from non-whitelisted origin/)
);
assert.isTrue(source.postMessage.notCalled);
assert.isTrue(frame.postMessage.notCalled);
});
});
it("responds with an error if there's no method", function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
let jsonRpcRequest = { jsonrpc: '2.0', id: 42 }; // No "method" member.
postMessage({
fakeWindow.emitter.emit('message', {
origin: 'https://allowed1.com',
data: jsonRpcRequest,
source: source,
source: frame,
});
assert.isTrue(source.postMessage.calledOnce);
assert.isTrue(frame.postMessage.calledOnce);
assert.isTrue(
source.postMessage.calledWithExactly(
frame.postMessage.calledWithExactly(
{
jsonrpc: '2.0',
id: 42,
......@@ -188,17 +178,17 @@ describe('crossOriginRPC', function() {
['unknownMethod', null].forEach(function(method) {
it('responds with an error if the method is unknown', function() {
crossOriginRPC.server.start(fakeStore, settings, fakeWindow);
startServer(fakeStore, settings, fakeWindow);
postMessage({
fakeWindow.emitter.emit('message', {
origin: 'https://allowed1.com',
data: { jsonrpc: '2.0', method: method, id: 42 },
source: source,
source: frame,
});
assert.isTrue(source.postMessage.calledOnce);
assert.isTrue(frame.postMessage.calledOnce);
assert.isTrue(
source.postMessage.calledWithExactly(
frame.postMessage.calledWithExactly(
{
jsonrpc: '2.0',
id: 42,
......@@ -213,4 +203,70 @@ describe('crossOriginRPC', function() {
});
});
});
describe('#preStartServer', function() {
beforeEach(function() {
preStartServer(fakeWindow);
});
it('responds to an incoming request that arrives before the server starts', function() {
fakeWindow.emitter.emit('message', {
data: { jsonrpc: '2.0', method: 'changeFocusModeUser', id: 42 },
origin: 'https://allowed1.com',
source: frame,
});
startServer(fakeStore, settings, fakeWindow);
assert.isTrue(
frame.postMessage.calledWithExactly(
{
jsonrpc: '2.0',
id: 42,
result: 'ok',
},
'https://allowed1.com'
)
);
});
it('responds to multiple incoming requests that arrive before the server starts', function() {
const messageEvent = id => ({
data: { jsonrpc: '2.0', method: 'changeFocusModeUser', id },
origin: 'https://allowed1.com',
source: frame,
});
const response = id => [
{
jsonrpc: '2.0',
id,
result: 'ok',
},
'https://allowed1.com',
];
fakeWindow.emitter.emit('message', messageEvent(42));
fakeWindow.emitter.emit('message', messageEvent(43));
fakeWindow.emitter.emit('message', messageEvent(44));
startServer(fakeStore, settings, fakeWindow);
assert.equal(frame.postMessage.callCount, 3);
assert.isTrue(frame.postMessage.calledWithExactly(...response(42)));
assert.isTrue(frame.postMessage.calledWithExactly(...response(43)));
assert.isTrue(frame.postMessage.calledWithExactly(...response(44)));
});
it("does not respond to pre-start incoming requests if the origin isn't allowed", function() {
fakeWindow.emitter.emit('message', {
data: { jsonrpc: '2.0', method: 'changeFocusModeUser', id: 42 },
origin: 'https://fake.com',
source: frame,
});
startServer(fakeStore, settings, fakeWindow);
assert.calledWith(
fakeWarnOnce,
sinon.match(/Ignoring JSON-RPC request from non-whitelisted origin/)
);
});
});
});
......@@ -176,7 +176,7 @@ describe('sidebar.util.fetch-config', () => {
});
});
it('makes an RCP request to `requestConfig` ', async () => {
it('makes an RPC request to `requestConfig` ', async () => {
await fetchConfig({}, fakeWindow);
fakeJsonRpc.call.calledWithExactly(
fakeTopWindow,
......
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