diff --git a/remoting/remoting_webapp_files.gypi b/remoting/remoting_webapp_files.gypi index 3b0cb9f8cf9173..ba6057372bbec0 100644 --- a/remoting/remoting_webapp_files.gypi +++ b/remoting/remoting_webapp_files.gypi @@ -108,6 +108,7 @@ 'webapp/base/js/xmpp_login_handler_unittest.js', 'webapp/base/js/xmpp_stream_parser_unittest.js', 'webapp/crd/js/apps_v2_migration_unittest.js', + 'webapp/crd/js/combined_host_list_api_unittest.js', 'webapp/crd/js/gcd_client_unittest.js', 'webapp/crd/js/gcd_client_with_mock_xhr_unittest.js', 'webapp/crd/js/host_controller_unittest.js', @@ -286,6 +287,7 @@ # JSCompiler. If an implementation of an interface occurs in a # file processed before the interface itself, the @override tag # doesn't always work correctly. + 'webapp/crd/js/combined_host_list_api.js', 'webapp/crd/js/gcd_host_list_api.js', 'webapp/crd/js/legacy_host_list_api.js', ], diff --git a/remoting/webapp/base/js/host.js b/remoting/webapp/base/js/host.js index ac37fe01c970c0..cff06de53fb312 100644 --- a/remoting/webapp/base/js/host.js +++ b/remoting/webapp/base/js/host.js @@ -27,7 +27,10 @@ remoting.Host = function(hostId) { this.hostId = hostId; /** @type {string} */ this.hostName = ''; - /** @type {string} */ + /** + * Either 'ONLINE' or 'OFFLINE'. + * @type {string} + */ this.status = ''; /** @type {string} */ this.jabberId = ''; diff --git a/remoting/webapp/crd/js/combined_host_list_api.js b/remoting/webapp/crd/js/combined_host_list_api.js new file mode 100644 index 00000000000000..681a4dac087fe8 --- /dev/null +++ b/remoting/webapp/crd/js/combined_host_list_api.js @@ -0,0 +1,197 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview + * API implementation that combines two other implementations. + */ + +/** @suppress {duplicate} */ +var remoting = remoting || {}; + +(function() { + +'use strict'; + +/** + * Amount of time to wait for GCD results after legacy registry has + * returned. + */ +var GCD_TIMEOUT_MS = 1000; + +/** + * @constructor + * @param {!remoting.HostListApi} legacyImpl + * @param {!remoting.HostListApi} gcdImpl + * @implements {remoting.HostListApi} + */ +remoting.CombinedHostListApi = function(legacyImpl, gcdImpl) { + /** @const {!remoting.HostListApi} */ + this.legacyImpl_ = legacyImpl; + + /** @const {!remoting.HostListApi} */ + this.gcdImpl_ = gcdImpl; + + /** + * List of host IDs most recently retrieved from |legacyImpl_|. + * @type {!Set} + */ + this.legacyIds_ = new Set(); + + /** + * List of host IDs most recently retrieved |gcdImpl_|. + * @type {!Set} + */ + this.gcdIds_ = new Set(); +}; + +/** @override */ +remoting.CombinedHostListApi.prototype.register = function( + hostName, publicKey, hostClientId) { + var that = this; + // First, register the new host with GCD, which will create a + // service account and generate a host ID. + return this.gcdImpl_.register(hostName, publicKey, hostClientId).then( + function(gcdRegResult) { + // After the GCD registration has been created, copy the + // registration to the legacy directory so that clients not yet + // upgraded to use GCD can see the new host. + // + // This is an ugly hack for multiple reasons: + // + // 1. It completely ignores |this.legacyImpl_|, complicating + // unit tests. + // + // 2. It relies on the fact that, when |hostClientId| is null, + // the legacy directory will "register" a host without + // creating a service account. This is an obsolete feature + // of the legacy directory that is being revived for a new + // purpose. + // + // 3. It assumes the device ID generated by GCD is usable as a + // host ID by the legacy directory. Fortunately both systems + // use UUIDs. + return remoting.LegacyHostListApi.registerWithHostId( + gcdRegResult.hostId, hostName, publicKey, null).then( + function() { + // On success, return the result from GCD, ignoring + // the result returned by the legacy directory. + that.gcdIds_.add(gcdRegResult.hostId); + that.legacyIds_.add(gcdRegResult.hostId); + return gcdRegResult; + }, + function(error) { + console.warn( + 'Error copying host GCD host registration ' + + 'to legacy directory: ' + error); + throw error; + } + ); + }); +}; + +/** @override */ +remoting.CombinedHostListApi.prototype.get = function() { + // Fetch the host list from both directories and merge hosts that + // have the same ID. + var that = this; + var legacyPromise = this.legacyImpl_.get(); + var gcdPromise = this.gcdImpl_.get(); + return legacyPromise.then(function(legacyHosts) { + // If GCD is too slow, just act as if it had returned an empty + // result set. + var timeoutPromise = base.Promise.withTimeout( + gcdPromise, GCD_TIMEOUT_MS, []); + + // Combine host information from both directories. In the case of + // conflicting information, prefer information from whichever + // directory claims to have newer information. + return timeoutPromise.then(function(gcdHosts) { + // Update |that.gcdIds_| and |that.legacyIds_|. + that.gcdIds_ = new Set(); + that.legacyIds_ = new Set(); + gcdHosts.forEach(function(host) { + that.gcdIds_.add(host.hostId); + }); + legacyHosts.forEach(function(host) { + that.legacyIds_.add(host.hostId); + }); + + /** + * A mapping from host IDs to the host data that will be + * returned from this method. + * @type {!Map} + */ + var hostMap = new Map(); + + // Add legacy hosts to the output; some of these may be replaced + // by GCD hosts. + legacyHosts.forEach(function(host) { + hostMap.set(host.hostId, host); + }); + + // Add GCD hosts to the output, possibly replacing some legacy + // host data with newer data from GCD. + gcdHosts.forEach(function(gcdHost) { + var hostId = gcdHost.hostId; + var legacyHost = hostMap.get(hostId); + if (!legacyHost || legacyHost.updatedTime <= gcdHost.updatedTime) { + hostMap.set(hostId, gcdHost); + } + }); + + // Convert the result to an Array. + // TODO(jrw): Use Array.from once it becomes available. + var hosts = []; + hostMap.forEach(function(host) { + hosts.push(host); + }); + return hosts; + }); + }); +}; + +/** @override */ +remoting.CombinedHostListApi.prototype.put = + function(hostId, hostName, hostPublicKey) { + var legacyPromise = Promise.resolve(); + if (this.legacyIds_.has(hostId)) { + legacyPromise = this.legacyImpl_.put(hostId, hostName, hostPublicKey); + } + var gcdPromise = Promise.resolve(); + if (this.gcdIds_.has(hostId)) { + gcdPromise = this.gcdImpl_.put(hostId, hostName, hostPublicKey); + } + return legacyPromise.then(function() { + // If GCD is too slow, just ignore it and return result from the + // legacy directory. + return base.Promise.withTimeout( + gcdPromise, GCD_TIMEOUT_MS); + }); +}; + +/** @override */ +remoting.CombinedHostListApi.prototype.remove = function(hostId) { + var legacyPromise = Promise.resolve(); + if (this.legacyIds_.has(hostId)) { + legacyPromise = this.legacyImpl_.remove(hostId); + } + var gcdPromise = Promise.resolve(); + if (this.gcdIds_.has(hostId)) { + gcdPromise = this.gcdImpl_.remove(hostId); + } + return legacyPromise.then(function() { + // If GCD is too slow, just ignore it and return result from the + // legacy directory. + return base.Promise.withTimeout( + gcdPromise, GCD_TIMEOUT_MS); + }); +}; + +/** @override */ +remoting.CombinedHostListApi.prototype.getSupportHost = function(supportId) { + return this.legacyImpl_.getSupportHost(supportId); +}; + +})(); diff --git a/remoting/webapp/crd/js/combined_host_list_api_unittest.js b/remoting/webapp/crd/js/combined_host_list_api_unittest.js new file mode 100644 index 00000000000000..f8c3d36618cfa0 --- /dev/null +++ b/remoting/webapp/crd/js/combined_host_list_api_unittest.js @@ -0,0 +1,176 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview + * Unit tests for combined_host_list_api.js. + */ + +(function() { + +'use strict'; + +/** @type {!remoting.MockHostListApi} */ +var mockGcdApi; + +/** @type {!remoting.MockHostListApi} */ +var mockLegacyApi; + +/** @type {!remoting.CombinedHostListApi} */ +var combinedApi; + +/** @type {sinon.TestStub} */ +var registerWithHostIdStub; + +/** @type {!remoting.Host} */ +var commonHostGcd; + +/** @type {!remoting.Host} */ +var commonHostLegacy; + +QUnit.module('CombinedHostListApi', { + beforeEach: function(/** QUnit.Assert */ assert) { + remoting.settings = new remoting.Settings(); + remoting.settings['USE_GCD'] = true; + remoting.mockIdentity.setAccessToken( + remoting.MockIdentity.AccessToken.VALID); + mockGcdApi = new remoting.MockHostListApi(); + mockGcdApi.addMockHost('gcd-host'); + commonHostGcd = mockGcdApi.addMockHost('common-host'); + commonHostGcd.hostName = 'common-host-gcd'; + mockLegacyApi = new remoting.MockHostListApi(); + mockLegacyApi.addMockHost('legacy-host'); + commonHostLegacy = mockLegacyApi.addMockHost('common-host'); + commonHostLegacy.hostName = 'common-host-legacy'; + combinedApi = new remoting.CombinedHostListApi(mockLegacyApi, mockGcdApi); + registerWithHostIdStub = + sinon.stub(remoting.LegacyHostListApi, 'registerWithHostId'); + }, + afterEach: function(/** QUnit.Assert */ assert) { + remoting.settings = null; + registerWithHostIdStub.restore(); + } +}); + +QUnit.test('register', function(/** QUnit.Assert */ assert) { + registerWithHostIdStub.returns(Promise.resolve()); + + mockGcdApi.authCodeFromRegister = ''; + mockGcdApi.emailFromRegister = ''; + mockGcdApi.hostIdFromRegister = ''; + mockLegacyApi.authCodeFromRegister = ''; + mockLegacyApi.emailFromRegister = ''; + mockLegacyApi.hostIdFromRegister = ''; + return combinedApi.register('', '', '').then(function(regResult) { + assert.equal(regResult.authCode, ''); + assert.equal(regResult.email, ''); + assert.equal(regResult.hostId, ''); + }); +}); + +QUnit.test('get', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function(hosts) { + assert.equal(hosts.length, 3); + var hostIds = new Set(); + hosts.forEach(function(host) { + hostIds.add(host.hostId); + if (host.hostId == 'common-host') { + assert.equal(host.hostName, 'common-host-gcd'); + }; + }); + assert.ok(hostIds.has('gcd-host')); + assert.ok(hostIds.has('legacy-host')); + assert.ok(hostIds.has('common-host')); + }); +}); + +QUnit.test('get w/ GCD newer', function(/** QUnit.Assert */ assert) { + commonHostGcd.updatedTime = '1970-01-02'; + commonHostLegacy.updatedTime = '1970-01-01'; + return combinedApi.get().then(function(hosts) { + hosts.forEach(function(host) { + if (host.hostId == 'common-host') { + assert.equal(host.hostName, 'common-host-gcd'); + }; + }); + }); +}); + +QUnit.test('get w/ legacy newer', function(/** QUnit.Assert */ assert) { + commonHostGcd.updatedTime = '1970-01-01'; + commonHostLegacy.updatedTime = '1970-01-02'; + return combinedApi.get().then(function(hosts) { + hosts.forEach(function(host) { + if (host.hostId == 'common-host') { + assert.equal(host.hostName, 'common-host-legacy'); + }; + }); + }); +}); + +QUnit.test('put to legacy', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.put('legacy-host', 'new host name', '').then( + function() { + assert.equal(mockLegacyApi.hosts[0].hostName, + 'new host name'); + }); + }); +}); + +QUnit.test('put to GCD', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.put('gcd-host', 'new host name', '').then( + function() { + assert.equal(mockGcdApi.hosts[0].hostName, + 'new host name'); + }); + }); +}); + + +QUnit.test('put to both', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.put('common-host', 'new host name', '').then( + function() { + assert.equal(mockGcdApi.hosts[1].hostName, + 'new host name'); + assert.equal(mockLegacyApi.hosts[1].hostName, + 'new host name'); + }); + }); +}); + +QUnit.test('remove from legacy', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.remove('legacy-host').then(function() { + assert.equal(mockGcdApi.hosts.length, 2); + assert.equal(mockLegacyApi.hosts.length, 1); + assert.notEqual(mockLegacyApi.hosts[0].hostId, 'legacy-host'); + }); + }); +}); + +QUnit.test('remove from gcd', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.remove('gcd-host').then(function() { + assert.equal(mockLegacyApi.hosts.length, 2); + assert.equal(mockGcdApi.hosts.length, 1); + assert.notEqual(mockGcdApi.hosts[0].hostId, 'gcd-host'); + }); + }); +}); + +QUnit.test('remove from both', function(/** QUnit.Assert */ assert) { + return combinedApi.get().then(function() { + return combinedApi.remove('common-host').then(function() { + assert.equal(mockGcdApi.hosts.length, 1); + assert.equal(mockLegacyApi.hosts.length, 1); + assert.notEqual(mockGcdApi.hosts[0].hostId, 'common-host'); + assert.notEqual(mockLegacyApi.hosts[0].hostId, 'common-host'); + }); + }); +}); + +})(); \ No newline at end of file diff --git a/remoting/webapp/crd/js/host_controller.js b/remoting/webapp/crd/js/host_controller.js index a697af31678642..613396946471ca 100644 --- a/remoting/webapp/crd/js/host_controller.js +++ b/remoting/webapp/crd/js/host_controller.js @@ -352,7 +352,7 @@ remoting.HostController.prototype.updatePin = function(newPin, onDone, return; } /** @type {string} */ - var hostId = config['host_id']; + var hostId = base.getStringAttr(config, 'host_id'); that.hostDaemonFacade_.getPinHash(hostId, newPin).then( updateDaemonConfigWithHash, remoting.Error.handler(onError)); } diff --git a/remoting/webapp/crd/js/host_list_api.js b/remoting/webapp/crd/js/host_list_api.js index 9387fb0611df8a..00ac4a24b067a4 100644 --- a/remoting/webapp/crd/js/host_list_api.js +++ b/remoting/webapp/crd/js/host_list_api.js @@ -80,9 +80,13 @@ var instance = null; */ remoting.HostListApi.getInstance = function() { if (instance == null) { - instance = remoting.settings.USE_GCD ? - new remoting.GcdHostListApi() : - new remoting.LegacyHostListApi(); + if (remoting.settings.USE_GCD) { + var gcdInstance = new remoting.GcdHostListApi(); + var legacyInstance = new remoting.LegacyHostListApi(); + instance = new remoting.CombinedHostListApi(legacyInstance, gcdInstance); + } else { + instance = new remoting.LegacyHostListApi(); + } } return instance; }; diff --git a/remoting/webapp/crd/js/legacy_host_list_api.js b/remoting/webapp/crd/js/legacy_host_list_api.js index c2b004ec56edd4..1c905c3a943e1d 100644 --- a/remoting/webapp/crd/js/legacy_host_list_api.js +++ b/remoting/webapp/crd/js/legacy_host_list_api.js @@ -25,6 +25,22 @@ remoting.LegacyHostListApi = function() { remoting.LegacyHostListApi.prototype.register = function( hostName, publicKey, hostClientId) { var newHostId = base.generateUuid(); + return remoting.LegacyHostListApi.registerWithHostId( + newHostId, hostName, publicKey, hostClientId); +}; + +/** + * Registers a host with the Chromoting directory using a specified + * host ID, which should not be equal to the ID of any existing host. + * + * @param {string} newHostId The host ID of the new host. + * @param {string} hostName The user-visible name of the new host. + * @param {string} publicKey The public half of the host's key pair. + * @param {?string} hostClientId The OAuth2 client ID of the host. + * @return {!Promise} + */ +remoting.LegacyHostListApi.registerWithHostId = function( + newHostId, hostName, publicKey, hostClientId) { var newHostDetails = { data: { hostId: newHostId, hostName: hostName, @@ -44,7 +60,9 @@ remoting.LegacyHostListApi.prototype.register = function( if (response.status == 200) { var result = /** @type {!Object} */ (response.getJson()); var data = base.getObjectAttr(result, 'data'); - var authCode = base.getStringAttr(data, 'authorizationCode'); + var authCode = hostClientId ? + base.getStringAttr(data, 'authorizationCode') : + ''; return { authCode: authCode, email: '', diff --git a/remoting/webapp/crd/js/legacy_host_list_api_unittest.js b/remoting/webapp/crd/js/legacy_host_list_api_unittest.js index ef2b598bcd8172..045a77872819c0 100644 --- a/remoting/webapp/crd/js/legacy_host_list_api_unittest.js +++ b/remoting/webapp/crd/js/legacy_host_list_api_unittest.js @@ -66,7 +66,7 @@ QUnit.test('register', function(assert) { FAKE_HOST_NAME, FAKE_PUBLIC_KEY, FAKE_HOST_CLIENT_ID - ). then(function(regResult) { + ).then(function(regResult) { assert.equal(regResult.authCode, FAKE_AUTH_CODE); assert.equal(regResult.email, ''); }); diff --git a/remoting/webapp/crd/js/mock_host_list_api.js b/remoting/webapp/crd/js/mock_host_list_api.js index 3cead52c60e328..8d5452a7f02820 100644 --- a/remoting/webapp/crd/js/mock_host_list_api.js +++ b/remoting/webapp/crd/js/mock_host_list_api.js @@ -32,32 +32,25 @@ remoting.MockHostListApi = function() { this.emailFromRegister = null; /** - * This host ID to return from register(), or null if it should fail. + * The host ID to return from register(), or null if it should fail. * @type {?string} */ this.hostIdFromRegister = null; - /** @type {Array} */ - this.hosts = [ - { - 'hostName': 'Online host', - 'hostId': 'online-host-id', - 'status': 'ONLINE', - 'jabberId': 'online-jid', - 'publicKey': 'online-public-key', - 'tokenUrlPatterns': [], - 'updatedTime': new Date().toISOString() - }, - { - 'hostName': 'Offline host', - 'hostId': 'offline-host-id', - 'status': 'OFFLINE', - 'jabberId': 'offline-jid', - 'publicKey': 'offline-public-key', - 'tokenUrlPatterns': [], - 'updatedTime': new Date(1970, 1, 1).toISOString() - } - ]; + /** @type {!Array} */ + this.hosts = []; +}; + +/** + * Creates and adds a new mock host. + * + * @param {string} hostId The ID of the new host to add. + * @return {!remoting.Host} the new mock host + */ +remoting.MockHostListApi.prototype.addMockHost = function(hostId) { + var newHost = new remoting.Host(hostId); + this.hosts.push(newHost); + return newHost; }; /** @override */ @@ -79,11 +72,7 @@ remoting.MockHostListApi.prototype.register = function( /** @override */ remoting.MockHostListApi.prototype.get = function() { - var that = this; - return new Promise(function(resolve, reject) { - remoting.mockIdentity.validateTokenAndCall( - resolve, remoting.Error.handler(reject), [that.hosts]); - }); + return Promise.resolve(this.hosts); }; /** @@ -97,22 +86,19 @@ remoting.MockHostListApi.prototype.put = /** @type {remoting.MockHostListApi} */ var that = this; return new Promise(function(resolve, reject) { - var onTokenValid = function() { - for (var i = 0; i < that.hosts.length; ++i) { - /** type {remoting.Host} */ - var host = that.hosts[i]; - if (host.hostId == hostId) { - host.hostName = hostName; - host.hostPublicKey = hostPublicKey; - resolve(undefined); - return; - } + for (var i = 0; i < that.hosts.length; ++i) { + /** type {remoting.Host} */ + var host = that.hosts[i]; + if (host.hostId == hostId) { + host.hostName = hostName; + host.hostPublicKey = hostPublicKey; + resolve(undefined); + return; } - console.error('PUT request for unknown host: ' + hostId + - ' (' + hostName + ')'); - reject(remoting.Error.unexpected()); - }; - remoting.mockIdentity.validateTokenAndCall(onTokenValid, reject, []); + } + console.error('PUT request for unknown host: ' + hostId + + ' (' + hostName + ')'); + reject(remoting.Error.unexpected()); }); }; @@ -124,30 +110,22 @@ remoting.MockHostListApi.prototype.remove = function(hostId) { /** @type {remoting.MockHostListApi} */ var that = this; return new Promise(function(resolve, reject) { - var onTokenValid = function() { - for (var i = 0; i < that.hosts.length; ++i) { - var host = that.hosts[i]; - if (host.hostId == hostId) { - that.hosts.splice(i, 1); - resolve(undefined); - return; - } + for (var i = 0; i < that.hosts.length; ++i) { + var host = that.hosts[i]; + if (host.hostId == hostId) { + that.hosts.splice(i, 1); + resolve(undefined); + return; } - console.error('DELETE request for unknown host: ' + hostId); - reject(remoting.Error.unexpected()); - }; - remoting.mockIdentity.validateTokenAndCall(onTokenValid, reject, []); + } + console.error('DELETE request for unknown host: ' + hostId); + reject(remoting.Error.unexpected()); }); }; /** @override */ remoting.MockHostListApi.prototype.getSupportHost = function(supportId) { - var that = this; - return new Promise(function(resolve, reject) { - remoting.mockIdentity.validateTokenAndCall( - resolve, remoting.Error.handler(reject), [that.hosts[0]]); - }); - + return Promise.resolve(this.hosts[0]); }; /**