@webex/webex-core 3.12.0-next.9 → 3.12.0-webex-services-ready.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,12 @@ const DEFAULT_CLUSTER_IDENTIFIER =
32
32
  const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap';
33
33
  const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
34
34
 
35
+ // Maximum time we will wait for the initial catalog collection before letting
36
+ // `services.ready` (and therefore `webex.ready`) fire anyway. A hung request
37
+ // must never leave the app on a permanent spinner - past this point downstream
38
+ // consumers must fall through to their normal error/login paths.
39
+ const SERVICES_INIT_TIMEOUT_MS = 15_000;
40
+
35
41
  /* eslint-disable no-underscore-dangle */
36
42
  /**
37
43
  * @class
@@ -44,6 +50,20 @@ const Services = WebexPlugin.extend({
44
50
  initFailed: ['boolean', false, false],
45
51
  },
46
52
 
53
+ session: {
54
+ /**
55
+ * Becomes `true` once services initialization has completed.
56
+ * This blocks `webex.ready` until services are initialized.
57
+ * @instance
58
+ * @memberof Services
59
+ * @type {boolean}
60
+ */
61
+ ready: {
62
+ default: false,
63
+ type: 'boolean',
64
+ },
65
+ },
66
+
47
67
  _catalogs: new WeakMap(),
48
68
 
49
69
  _activeServices: {},
@@ -1337,6 +1357,30 @@ const Services = WebexPlugin.extend({
1337
1357
  );
1338
1358
  },
1339
1359
 
1360
+ /**
1361
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1362
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1363
+ * refresh is in flight when initial catalog collection settles, we must not
1364
+ * signal ready until the refresh has resolved - otherwise downstream
1365
+ * consumers may observe `canAuthorize`/token state that is about to change
1366
+ * under them.
1367
+ *
1368
+ * @private
1369
+ * @returns {Promise<void>}
1370
+ */
1371
+ async _finalizeReady(): Promise<void> {
1372
+ const {credentials} = this.webex;
1373
+
1374
+ if (credentials && credentials.isRefreshing) {
1375
+ await new Promise<void>((resolve) => {
1376
+ credentials.once('change:isRefreshing', resolve);
1377
+ });
1378
+ }
1379
+
1380
+ this.ready = true;
1381
+ this.trigger('services:initialized');
1382
+ },
1383
+
1340
1384
  /**
1341
1385
  * Initializer
1342
1386
  *
@@ -1353,19 +1397,32 @@ const Services = WebexPlugin.extend({
1353
1397
  this.initConfig();
1354
1398
  });
1355
1399
 
1356
- // wait for webex instance to be ready before attempting
1357
- // to update the service catalogs
1358
- this.listenToOnce(this.webex, 'ready', async () => {
1400
+ // Wait for storage to be loaded before attempting to update the service catalogs.
1401
+ // We listen for 'loaded' instead of 'ready' because services.ready is a dependency
1402
+ // of webex.ready - listening to 'ready' would cause a deadlock.
1403
+ this.listenToOnce(this.webex, 'loaded', async () => {
1359
1404
  const warmed = await this._loadCatalogFromCache();
1360
1405
  if (warmed) {
1361
1406
  catalog.isReady = true;
1407
+ await this._finalizeReady();
1362
1408
 
1363
1409
  return;
1364
1410
  }
1365
1411
  const {supertoken} = this.webex.credentials;
1412
+
1413
+ // Race init against a hard timeout so a hung request never leaves
1414
+ // `services.ready` false forever - that would stall `webex.ready` and
1415
+ // leave the app on a permanent spinner.
1416
+ const timeout = new Promise<never>((_, reject) => {
1417
+ setTimeout(
1418
+ () => reject(new Error(`services: init timed out after ${SERVICES_INIT_TIMEOUT_MS}ms`)),
1419
+ SERVICES_INIT_TIMEOUT_MS
1420
+ );
1421
+ });
1422
+
1366
1423
  // Validate if the supertoken exists.
1367
1424
  if (supertoken && supertoken.access_token) {
1368
- this.initServiceCatalogs()
1425
+ Promise.race([this.initServiceCatalogs(), timeout])
1369
1426
  .then(() => {
1370
1427
  catalog.isReady = true;
1371
1428
  })
@@ -1374,15 +1431,33 @@ const Services = WebexPlugin.extend({
1374
1431
  this.logger.error(
1375
1432
  `services: failed to init initial services when credentials available, ${error?.message}`
1376
1433
  );
1377
- });
1434
+ })
1435
+ .finally(() => this._finalizeReady());
1378
1436
  } else {
1379
1437
  const {email} = this.webex.config;
1380
1438
 
1381
- this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1382
- this.initFailed = true;
1383
- this.logger.error(
1384
- `services: failed to init initial services when no credentials available, ${error?.message}`
1385
- );
1439
+ Promise.race([this.collectPreauthCatalog(email ? {email} : undefined), timeout])
1440
+ .catch((error) => {
1441
+ this.initFailed = true;
1442
+ this.logger.error(
1443
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1444
+ );
1445
+ })
1446
+ .finally(() => this._finalizeReady());
1447
+ // Listen for when credentials become available to fetch the full catalog.
1448
+ // This handles fresh login where 'loaded' fires before OAuth completes.
1449
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1450
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1451
+ this.initServiceCatalogs()
1452
+ .then(() => {
1453
+ catalog.isReady = true;
1454
+ })
1455
+ .catch((error) => {
1456
+ this.logger.error(
1457
+ `services: failed to init service catalogs after auth, ${error?.message}`
1458
+ );
1459
+ });
1460
+ }
1386
1461
  });
1387
1462
  }
1388
1463
  });
@@ -23,13 +23,12 @@ describe('webex-core', () => {
23
23
  .then(
24
24
  ([user]) =>
25
25
  new Promise((resolve) => {
26
- setTimeout(() => {
27
- webexUser = user;
28
- webex = new WebexCore({credentials: user.token});
29
- services = webex.internal.services;
30
- catalog = services._getCatalog();
31
- resolve();
32
- }, 1000);
26
+ webexUser = user;
27
+ webex = new WebexCore({credentials: user.token});
28
+ services = webex.internal.services;
29
+ catalog = services._getCatalog();
30
+ // Wait for webex ready event before registering device to ensure newMetrics.callDiagnosticMetrics is initialized
31
+ webex.once('ready', resolve);
33
32
  })
34
33
  )
35
34
  .then(() => webex.internal.device.register())
@@ -404,15 +404,26 @@ describe('webex-core', () => {
404
404
  assert.isTrue(catalog.isReady);
405
405
  });
406
406
 
407
- it('should call services#initServiceCatalogs() on webex ready', async () => {
408
- services._loadCatalogFromCache = sinon.stub().resolves(false);
407
+ it('should call services#initServiceCatalogs() on webex loaded', async () => {
409
408
  services.initServiceCatalogs = sinon.stub().resolves();
410
409
  services.initialize();
411
- webex.trigger('ready');
412
- // Wait for the async 'ready' handler to complete
413
- await new Promise((resolve) => setTimeout(resolve, 50));
410
+ webex.trigger('loaded');
411
+ // Wait for the async callback to execute
412
+ await new Promise((resolve) => setTimeout(resolve, 10));
414
413
  assert.called(services.initServiceCatalogs);
415
- assert.isTrue(catalog.isReady);
414
+ });
415
+
416
+ it('should set services.ready to true after initialization completes', async () => {
417
+ // services.ready starts as false
418
+ const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
419
+ const newServices = newWebex.internal.services;
420
+
421
+ // Wait for initialization to complete
422
+ await new Promise((resolve) => {
423
+ newServices.on('services:initialized', resolve);
424
+ });
425
+
426
+ assert.isTrue(newServices.ready);
416
427
  });
417
428
 
418
429
  it('should collect different catalogs based on OrgId region', () =>
@@ -316,15 +316,26 @@ describe('webex-core', () => {
316
316
  assert.isTrue(catalog.isReady);
317
317
  });
318
318
 
319
- it('should call services#initServiceCatalogs() on webex ready', async () => {
320
- services._loadCatalogFromCache = sinon.stub().resolves(false);
319
+ it('should call services#initServiceCatalogs() on webex loaded', async () => {
321
320
  services.initServiceCatalogs = sinon.stub().resolves();
322
321
  services.initialize();
323
- webex.trigger('ready');
324
- // Wait for the async 'ready' handler to complete
325
- await new Promise((resolve) => setTimeout(resolve, 50));
322
+ webex.trigger('loaded');
323
+ // Wait for the async callback to execute
324
+ await new Promise((resolve) => setTimeout(resolve, 10));
326
325
  assert.called(services.initServiceCatalogs);
327
- assert.isTrue(catalog.isReady);
326
+ });
327
+
328
+ it('should set services.ready to true after initialization completes', async () => {
329
+ // services.ready starts as false
330
+ const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
331
+ const newServices = newWebex.internal.services;
332
+
333
+ // Wait for initialization to complete
334
+ await new Promise((resolve) => {
335
+ newServices.on('services:initialized', resolve);
336
+ });
337
+
338
+ assert.isTrue(newServices.ready);
328
339
  });
329
340
 
330
341
  it('should collect different catalogs based on OrgId region', () =>
@@ -183,6 +183,10 @@ describe('webex-core', () => {
183
183
  webex.credentials.buildLoginUrl({state: 'state'});
184
184
  }, /if specified, `options.state` must be an object/);
185
185
 
186
+ assert.throws(() => {
187
+ webex.credentials.buildLoginUrl({state: null});
188
+ }, /if specified, `options.state` must be an object/);
189
+
186
190
  assert.doesNotThrow(() => {
187
191
  webex.credentials.buildLoginUrl({state: {}});
188
192
  }, /if specified, `options.state` must be an object/);
@@ -232,6 +236,132 @@ describe('webex-core', () => {
232
236
  });
233
237
  });
234
238
 
239
+ describe('#buildThirdPartyLoginUrl()', () => {
240
+ it('throws if both `oauth2provider` and `returnURL` are missing', () => {
241
+ const webex = new MockWebex();
242
+ const credentials = new Credentials(undefined, {parent: webex});
243
+
244
+ webex.trigger('change:config');
245
+
246
+ assert.throws(() => {
247
+ credentials.buildThirdPartyLoginUrl({});
248
+ }, /`options.oauth2provider` is required/);
249
+ });
250
+
251
+ it('throws if `oauth2provider` is missing', () => {
252
+ const webex = new MockWebex();
253
+ const credentials = new Credentials(undefined, {parent: webex});
254
+
255
+ webex.trigger('change:config');
256
+
257
+ assert.throws(() => {
258
+ credentials.buildThirdPartyLoginUrl({returnURL: 'https://web.webex.com'});
259
+ }, /`options.oauth2provider` is required/);
260
+ });
261
+
262
+ it('throws if `returnURL` is missing', () => {
263
+ const webex = new MockWebex();
264
+ const credentials = new Credentials(undefined, {parent: webex});
265
+
266
+ webex.trigger('change:config');
267
+
268
+ assert.throws(() => {
269
+ credentials.buildThirdPartyLoginUrl({oauth2provider: 'google'});
270
+ }, /`options.returnURL` is required/);
271
+ });
272
+
273
+ skipInBrowser(it)('generates the third-party login url', () => {
274
+ const webex = new MockWebex();
275
+ const credentials = new Credentials(undefined, {parent: webex});
276
+
277
+ webex.trigger('change:config');
278
+
279
+ assert.equal(
280
+ credentials.buildThirdPartyLoginUrl({
281
+ oauth2provider: 'google',
282
+ returnURL: 'https://web.webex.com',
283
+ }),
284
+ `${
285
+ process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
286
+ }/idb/ThirdPartyLogin?oauth2provider=google&returnURL=https%3A%2F%2Fweb.webex.com`
287
+ );
288
+ });
289
+
290
+ skipInBrowser(it)('generates the url with different parameter values', () => {
291
+ const webex = new MockWebex();
292
+ const credentials = new Credentials(undefined, {parent: webex});
293
+
294
+ webex.trigger('change:config');
295
+
296
+ assert.equal(
297
+ credentials.buildThirdPartyLoginUrl({
298
+ oauth2provider: 'apple',
299
+ returnURL: 'https://example.com/callback',
300
+ }),
301
+ `${
302
+ process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
303
+ }/idb/ThirdPartyLogin?oauth2provider=apple&returnURL=https%3A%2F%2Fexample.com%2Fcallback`
304
+ );
305
+ });
306
+
307
+ it('throws if `state` is not an object', () => {
308
+ const webex = new MockWebex();
309
+ const credentials = new Credentials(undefined, {parent: webex});
310
+
311
+ webex.trigger('change:config');
312
+
313
+ assert.throws(() => {
314
+ credentials.buildThirdPartyLoginUrl({
315
+ oauth2provider: 'google',
316
+ returnURL: 'https://web.webex.com',
317
+ state: 'not-an-object',
318
+ });
319
+ }, /`options.state` must be an object/);
320
+ });
321
+
322
+ skipInBrowser(it)('omits `state` when an empty object is provided', () => {
323
+ const webex = new MockWebex();
324
+ const credentials = new Credentials(undefined, {parent: webex});
325
+
326
+ webex.trigger('change:config');
327
+
328
+ const result = credentials.buildThirdPartyLoginUrl({
329
+ oauth2provider: 'google',
330
+ returnURL: 'https://web.webex.com',
331
+ state: {},
332
+ });
333
+
334
+ const parsed = new URL(result);
335
+
336
+ assert.isFalse(parsed.searchParams.has('state'));
337
+ });
338
+
339
+ skipInBrowser(it)('base64url-encodes a non-empty `state` and emits it as a top-level query param', () => {
340
+ const webex = new MockWebex();
341
+ const credentials = new Credentials(undefined, {parent: webex});
342
+
343
+ webex.trigger('change:config');
344
+
345
+ const result = credentials.buildThirdPartyLoginUrl({
346
+ oauth2provider: 'google',
347
+ returnURL: 'https://web.webex.com',
348
+ state: {csrf_token: 'abc', popUpSignIn: true},
349
+ });
350
+
351
+ // Literal base64url of '{"csrf_token":"abc","popUpSignIn":true}'
352
+ const expectedState = 'eyJjc3JmX3Rva2VuIjoiYWJjIiwicG9wVXBTaWduSW4iOnRydWV9';
353
+
354
+ assert.equal(
355
+ result,
356
+ `${
357
+ process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
358
+ }/idb/ThirdPartyLogin?oauth2provider=google&returnURL=${encodeURIComponent(
359
+ 'https://web.webex.com'
360
+ )}&state=${expectedState}`
361
+ );
362
+ });
363
+ });
364
+
235
365
  describe('#buildLogoutUrl()', () => {
236
366
  skipInBrowser(it)('generates the logout url', () => {
237
367
  const webex = new MockWebex();
@@ -368,7 +498,9 @@ describe('webex-core', () => {
368
498
  });
369
499
 
370
500
  it('should throw when provided an invalid token', () =>
371
- expect(() => credentials.extractOrgIdFromUserToken()).toThrow('the provided token is not a valid format, token has 1 sections'));
501
+ expect(() => credentials.extractOrgIdFromUserToken()).toThrow(
502
+ 'the provided token is not a valid format, token has 1 sections'
503
+ ));
372
504
 
373
505
  it('should throw when no token is provided', () =>
374
506
  expect(() => credentials.extractOrgIdFromUserToken()).toThrow());
@@ -799,7 +931,6 @@ describe('webex-core', () => {
799
931
  .then(() => assert.isRejected(webex.boundedStorage.get('Credentials', '@'), /NotFound/));
800
932
  });
801
933
 
802
-
803
934
  // it('does not induce any token refreshes');
804
935
 
805
936
  it('prevents #getUserToken() from being invoked', () => {
@@ -7,6 +7,7 @@ import {assert} from '@webex/test-helper-chai';
7
7
  import MockWebex from '@webex/test-helper-mock-webex';
8
8
  import sinon from 'sinon';
9
9
  import {Batcher} from '@webex/webex-core';
10
+ import WebexHttpError from '../../../../src/lib/webex-http-error';
10
11
 
11
12
  function promiseTick(count) {
12
13
  let promise = Promise.resolve();
@@ -154,6 +155,61 @@ describe('webex-core', () => {
154
155
  return Promise.all([assert.isRejected(p1), assert.isRejected(p2)]);
155
156
  });
156
157
  });
158
+
159
+ it('does not trigger unhandledRejection when caller handles rejection', () => {
160
+ const unhandled = [];
161
+ const onUnhandledRejection = (reason) => {
162
+ unhandled.push(reason);
163
+ };
164
+
165
+ process.on('unhandledRejection', onUnhandledRejection);
166
+
167
+ const p = webex.internal.batcher.request(1);
168
+
169
+ // eslint-disable-next-line prefer-promise-reject-errors
170
+ webex.request.returns(Promise.reject({statusCode: 0}));
171
+
172
+ return promiseTick(50)
173
+ .then(() => clock.tick(2))
174
+ .then(() => promiseTick(50))
175
+ .then(() => assert.isRejected(p))
176
+ .then(() => promiseTick(50))
177
+ .then(() => {
178
+ assert.lengthOf(unhandled, 0);
179
+ })
180
+ .finally(() => {
181
+ process.removeListener('unhandledRejection', onUnhandledRejection);
182
+ });
183
+ });
184
+
185
+ it('fails queued deferreds for webex http errors without request body', () => {
186
+ const p1 = webex.internal.batcher.request(1);
187
+ const p2 = webex.internal.batcher.request(2);
188
+ const reason = new WebexHttpError.BadRequest({
189
+ statusCode: 400,
190
+ body: {message: 'simulated failure'},
191
+ options: {
192
+ method: 'GET',
193
+ uri: 'https://example.com/v1/mock/batch',
194
+ headers: {trackingid: 'test-tracking-id'},
195
+ },
196
+ headers: {},
197
+ });
198
+
199
+ webex.request.returns(Promise.reject(reason));
200
+
201
+ return promiseTick(50)
202
+ .then(() => clock.tick(2))
203
+ .then(() => promiseTick(50))
204
+ .then(() => {
205
+ assert.calledOnce(webex.request);
206
+
207
+ return Promise.all([
208
+ assert.isRejected(p1, /simulated failure/),
209
+ assert.isRejected(p2, /simulated failure/),
210
+ ]);
211
+ });
212
+ });
157
213
  });
158
214
 
159
215
  describe('when the number of request attempts exceeds a given threshold', () => {