@webex/webex-core 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.2

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.
Files changed (41) hide show
  1. package/dist/config.js +7 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/credentials-config.js +12 -0
  4. package/dist/credentials-config.js.map +1 -1
  5. package/dist/interceptors/redirect.js +1 -1
  6. package/dist/interceptors/redirect.js.map +1 -1
  7. package/dist/lib/batcher.js +23 -7
  8. package/dist/lib/batcher.js.map +1 -1
  9. package/dist/lib/credentials/credentials.js +48 -4
  10. package/dist/lib/credentials/credentials.js.map +1 -1
  11. package/dist/lib/credentials/token.js +1 -1
  12. package/dist/lib/services/service-url.js +11 -1
  13. package/dist/lib/services/service-url.js.map +1 -1
  14. package/dist/lib/services/services.js +583 -94
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +507 -41
  17. package/dist/lib/services-v2/services-v2.js.map +1 -1
  18. package/dist/lib/services-v2/types.js.map +1 -1
  19. package/dist/plugins/logger.js +1 -1
  20. package/dist/webex-core.js +2 -2
  21. package/dist/webex-core.js.map +1 -1
  22. package/package.json +13 -13
  23. package/src/config.js +7 -0
  24. package/src/credentials-config.js +13 -0
  25. package/src/interceptors/redirect.js +4 -1
  26. package/src/lib/batcher.js +25 -10
  27. package/src/lib/credentials/credentials.js +50 -3
  28. package/src/lib/services/service-url.js +9 -1
  29. package/src/lib/services/services.js +433 -6
  30. package/src/lib/services-v2/services-v2.ts +417 -2
  31. package/src/lib/services-v2/types.ts +5 -0
  32. package/test/integration/spec/services/service-catalog.js +16 -11
  33. package/test/integration/spec/services/services.js +58 -9
  34. package/test/integration/spec/services-v2/services-v2.js +49 -6
  35. package/test/unit/spec/credentials/credentials.js +133 -2
  36. package/test/unit/spec/lib/batcher.js +56 -0
  37. package/test/unit/spec/services/service-url.js +110 -0
  38. package/test/unit/spec/services/services.js +692 -12
  39. package/test/unit/spec/services-v2/services-v2.ts +539 -0
  40. package/test/unit/spec/webex-core.js +2 -0
  41. package/test/unit/spec/webex-internal-core.js +2 -0
@@ -23,7 +23,7 @@ import {
23
23
  formattedServiceHostmapEntryConv,
24
24
  formattedServiceHostmapEntryMercury,
25
25
  formattedServiceHostmapEntryTest,
26
- serviceHostmapV2
26
+ serviceHostmapV2,
27
27
  } from '../../../fixtures/host-catalog-v2';
28
28
 
29
29
  // /* eslint-disable no-underscore-dangle */
@@ -316,10 +316,13 @@ describe('webex-core', () => {
316
316
  assert.isTrue(catalog.isReady);
317
317
  });
318
318
 
319
- it('should call services#initServiceCatalogs() on webex ready', () => {
319
+ it('should call services#initServiceCatalogs() on webex ready', async () => {
320
+ services._loadCatalogFromCache = sinon.stub().resolves(false);
320
321
  services.initServiceCatalogs = sinon.stub().resolves();
321
322
  services.initialize();
322
323
  webex.trigger('ready');
324
+ // Wait for the async 'ready' handler to complete
325
+ await new Promise((resolve) => setTimeout(resolve, 50));
323
326
  assert.called(services.initServiceCatalogs);
324
327
  assert.isTrue(catalog.isReady);
325
328
  });
@@ -338,6 +341,39 @@ describe('webex-core', () => {
338
341
  done();
339
342
  }, 2000);
340
343
  });
344
+
345
+ it('blocks webex.ready until services.ready flips when waitForCatalogInit is enabled', async () => {
346
+ const gatedWebex = new WebexCore({
347
+ credentials: {supertoken: webexUser.token},
348
+ config: {services: {waitForCatalogInit: true}},
349
+ });
350
+
351
+ // Before init settles, webex.ready must be false because services.ready
352
+ // is a dependency and starts false in the gated path.
353
+ assert.isFalse(gatedWebex.internal.services.ready, 'services.ready should start false');
354
+ assert.isFalse(gatedWebex.ready, 'webex.ready should not fire while services.ready is false');
355
+
356
+ // Wait up to 30s for services init to complete and flip ready.
357
+ await new Promise((resolve, reject) => {
358
+ if (gatedWebex.internal.services.ready) {
359
+ resolve();
360
+
361
+ return;
362
+ }
363
+ const timer = setTimeout(
364
+ () => reject(new Error('timed out waiting for services.ready')),
365
+ 30_000
366
+ );
367
+
368
+ gatedWebex.internal.services.once('change:ready', () => {
369
+ clearTimeout(timer);
370
+ resolve();
371
+ });
372
+ });
373
+
374
+ assert.isTrue(gatedWebex.internal.services.ready, 'services.ready should flip true after init settles');
375
+ assert.isTrue(gatedWebex.ready, 'webex.ready should fire once services.ready flips');
376
+ });
341
377
  });
342
378
 
343
379
  describe('#initServiceCatalogs()', () => {
@@ -413,7 +449,11 @@ describe('webex-core', () => {
413
449
  .initServiceCatalogs(true)
414
450
  // services#updateServices() gets called once by the limited catalog
415
451
  // retrieval and should get called again when authorized.
416
- .then(() => assert.calledTwice(services.updateServices) && assert.calledWith(services.updateServices, sinon.match({forceRefresh: true})))
452
+ .then(
453
+ () =>
454
+ assert.calledTwice(services.updateServices) &&
455
+ assert.calledWith(services.updateServices, sinon.match({forceRefresh: true}))
456
+ )
417
457
  );
418
458
  });
419
459
  });
@@ -750,9 +790,12 @@ describe('webex-core', () => {
750
790
 
751
791
  const getActivationRequest = (requestStub, useUserOnboarding = false) => {
752
792
  const expectedService = useUserOnboarding ? 'user-onboarding' : 'license';
753
- const expectedResource = useUserOnboarding ? 'api/v1/users/activations' : 'users/activations';
793
+ const expectedResource = useUserOnboarding
794
+ ? 'api/v1/users/activations'
795
+ : 'users/activations';
754
796
  const requests = requestStub.args.filter(
755
- ([request]) => request.service === expectedService && request.resource === expectedResource
797
+ ([request]) =>
798
+ request.service === expectedService && request.resource === expectedResource
756
799
  );
757
800
 
758
801
  assert.strictEqual(requests.length, 1);
@@ -825,7 +868,7 @@ describe('webex-core', () => {
825
868
  assert.equal(r.user.verificationEmailTriggered, true);
826
869
  }));
827
870
 
828
- it.skip('validates new user with activationOptions suppressEmail true', () =>
871
+ it('validates new user with activationOptions suppressEmail true', () =>
829
872
  unauthServices
830
873
  .validateUser({
831
874
  email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
@@ -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', () => {
@@ -151,6 +151,116 @@ describe('webex-core', () => {
151
151
 
152
152
  assert.isTrue(homeClusterUrls.every((host) => !host.failed));
153
153
  });
154
+
155
+ describe('when hosts have negative priorities', () => {
156
+ it('should return defaultUrl when all hosts have negative priorities', () => {
157
+ const negativeServiceUrl = new ServiceUrl({
158
+ defaultUrl: 'https://default.example.com/api/v1',
159
+ hosts: [
160
+ {
161
+ host: 'example-host-neg1.com',
162
+ priority: -1,
163
+ ttl: -1,
164
+ id: '1',
165
+ homeCluster: true,
166
+ },
167
+ {
168
+ host: 'example-host-neg2.com',
169
+ priority: -1,
170
+ ttl: -1,
171
+ id: '2',
172
+ homeCluster: true,
173
+ },
174
+ ],
175
+ name: 'negative-priority-test',
176
+ });
177
+
178
+ assert.equal(
179
+ negativeServiceUrl._getPriorityHostUrl(),
180
+ 'https://default.example.com/api/v1'
181
+ );
182
+ });
183
+
184
+ it('should return defaultUrl when all hosts have zero priority', () => {
185
+ const zeroServiceUrl = new ServiceUrl({
186
+ defaultUrl: 'https://default.example.com/api/v1',
187
+ hosts: [
188
+ {
189
+ host: 'example-host-zero.com',
190
+ priority: 0,
191
+ ttl: -1,
192
+ id: '1',
193
+ homeCluster: true,
194
+ },
195
+ ],
196
+ name: 'zero-priority-test',
197
+ });
198
+
199
+ assert.equal(zeroServiceUrl._getPriorityHostUrl(), 'https://default.example.com/api/v1');
200
+ });
201
+
202
+ it('should ignore hosts with negative priorities and return valid host', () => {
203
+ const mixedServiceUrl = new ServiceUrl({
204
+ defaultUrl: 'https://default.example.com/api/v1',
205
+ hosts: [
206
+ {
207
+ host: 'example-host-neg.com',
208
+ priority: -1,
209
+ ttl: -1,
210
+ id: '1',
211
+ homeCluster: true,
212
+ },
213
+ {
214
+ host: 'example-host-valid.com',
215
+ priority: 5,
216
+ ttl: -1,
217
+ id: '2',
218
+ homeCluster: true,
219
+ },
220
+ ],
221
+ name: 'mixed-priority-test',
222
+ });
223
+
224
+ const result = mixedServiceUrl._getPriorityHostUrl();
225
+
226
+ assert.include(result, 'example-host-valid.com');
227
+ assert.notInclude(result, 'example-host-neg.com');
228
+ });
229
+
230
+ it('should select lowest positive priority host when mixed with negative priorities', () => {
231
+ const mixedServiceUrl = new ServiceUrl({
232
+ defaultUrl: 'https://default.example.com/api/v1',
233
+ hosts: [
234
+ {
235
+ host: 'example-host-neg.com',
236
+ priority: -1,
237
+ ttl: -1,
238
+ id: '1',
239
+ homeCluster: true,
240
+ },
241
+ {
242
+ host: 'example-host-p5.com',
243
+ priority: 5,
244
+ ttl: -1,
245
+ id: '2',
246
+ homeCluster: true,
247
+ },
248
+ {
249
+ host: 'example-host-p2.com',
250
+ priority: 2,
251
+ ttl: -1,
252
+ id: '3',
253
+ homeCluster: true,
254
+ },
255
+ ],
256
+ name: 'mixed-priority-test',
257
+ });
258
+
259
+ const result = mixedServiceUrl._getPriorityHostUrl();
260
+
261
+ assert.include(result, 'example-host-p2.com');
262
+ });
263
+ });
154
264
  });
155
265
 
156
266
  describe('#failHost()', () => {