@webex/webex-core 3.12.0-next.32 → 3.12.0-next.33

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.
@@ -25,6 +25,7 @@ import {
25
25
  formattedServiceHostmapEntryTest,
26
26
  serviceHostmapV2,
27
27
  } from '../../../fixtures/host-catalog-v2';
28
+ import {createActivationEmail} from '../../../fixtures/activation-email';
28
29
 
29
30
  // /* eslint-disable no-underscore-dangle */
30
31
  describe('webex-core', () => {
@@ -320,11 +321,13 @@ describe('webex-core', () => {
320
321
  services._loadCatalogFromCache = sinon.stub().resolves(false);
321
322
  services.initServiceCatalogs = sinon.stub().resolves();
322
323
  services.initialize();
324
+ // The mode-specific ('ready'/'loaded') listener is registered inside the
325
+ // change:config handler, so fire change:config first, then 'ready'.
326
+ webex.trigger('change:config');
323
327
  webex.trigger('ready');
324
328
  // Wait for the async 'ready' handler to complete
325
329
  await new Promise((resolve) => setTimeout(resolve, 50));
326
330
  assert.called(services.initServiceCatalogs);
327
- assert.isTrue(catalog.isReady);
328
331
  });
329
332
 
330
333
  it('should collect different catalogs based on OrgId region', () =>
@@ -341,6 +344,39 @@ describe('webex-core', () => {
341
344
  done();
342
345
  }, 2000);
343
346
  });
347
+
348
+ it('blocks webex.ready until services.ready flips when waitForCatalogInit is enabled', async () => {
349
+ const gatedWebex = new WebexCore({
350
+ credentials: {supertoken: webexUser.token},
351
+ config: {services: {waitForCatalogInit: true}},
352
+ });
353
+
354
+ // Before init settles, webex.ready must be false because services.ready
355
+ // is a dependency and starts false in the gated path.
356
+ assert.isFalse(gatedWebex.internal.services.ready, 'services.ready should start false');
357
+ assert.isFalse(gatedWebex.ready, 'webex.ready should not fire while services.ready is false');
358
+
359
+ // Wait up to 30s for services init to complete and flip ready.
360
+ await new Promise((resolve, reject) => {
361
+ if (gatedWebex.internal.services.ready) {
362
+ resolve();
363
+
364
+ return;
365
+ }
366
+ const timer = setTimeout(
367
+ () => reject(new Error('timed out waiting for services.ready')),
368
+ 30_000
369
+ );
370
+
371
+ gatedWebex.internal.services.once('change:ready', () => {
372
+ clearTimeout(timer);
373
+ resolve();
374
+ });
375
+ });
376
+
377
+ assert.isTrue(gatedWebex.internal.services.ready, 'services.ready should flip true after init settles');
378
+ assert.isTrue(gatedWebex.ready, 'webex.ready should fire once services.ready flips');
379
+ });
344
380
  });
345
381
 
346
382
  describe('#initServiceCatalogs()', () => {
@@ -815,7 +851,7 @@ describe('webex-core', () => {
815
851
 
816
852
  it('validates a non-existing user', () =>
817
853
  unauthServices
818
- .validateUser({email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`})
854
+ .validateUser({email: createActivationEmail()})
819
855
  .then((r) => {
820
856
  assert.hasAllKeys(r, ['activated', 'exists', 'user', 'details']);
821
857
  assert.equal(r.activated, false);
@@ -825,7 +861,7 @@ describe('webex-core', () => {
825
861
  it('validates new user with activationOptions suppressEmail false', () =>
826
862
  unauthServices
827
863
  .validateUser({
828
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
864
+ email: createActivationEmail(),
829
865
  activationOptions: {suppressEmail: false},
830
866
  })
831
867
  .then((r) => {
@@ -838,7 +874,7 @@ describe('webex-core', () => {
838
874
  it('validates new user with activationOptions suppressEmail true', () =>
839
875
  unauthServices
840
876
  .validateUser({
841
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
877
+ email: createActivationEmail(),
842
878
  activationOptions: {suppressEmail: true},
843
879
  })
844
880
  .then((r) => {
@@ -882,7 +918,7 @@ describe('webex-core', () => {
882
918
 
883
919
  return unauthServices
884
920
  .validateUser({
885
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
921
+ email: createActivationEmail(),
886
922
  activationOptions: {suppressEmail: true},
887
923
  })
888
924
  .then(() => {
@@ -896,7 +932,7 @@ describe('webex-core', () => {
896
932
 
897
933
  return unauthServices
898
934
  .validateUser({
899
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
935
+ email: createActivationEmail(),
900
936
  activationOptions: {suppressEmail: true},
901
937
  preloginUserId,
902
938
  })
@@ -913,7 +949,7 @@ describe('webex-core', () => {
913
949
 
914
950
  return unauthServices
915
951
  .validateUser({
916
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
952
+ email: createActivationEmail(),
917
953
  activationOptions: {suppressEmail: true},
918
954
  })
919
955
  .then(() => {
@@ -936,7 +972,7 @@ describe('webex-core', () => {
936
972
 
937
973
  return userOnboardingServices
938
974
  .validateUser({
939
- email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
975
+ email: createActivationEmail(),
940
976
  activationOptions: {suppressEmail: true},
941
977
  })
942
978
  .then(() => {
@@ -46,6 +46,7 @@ describe('webex-core', () => {
46
46
  services.initialize();
47
47
 
48
48
  // call the onReady callback
49
+ services.listenToOnce.getCall(0).args[2]();
49
50
  services.listenToOnce.getCall(1).args[2]();
50
51
  await waitForAsync();
51
52
 
@@ -59,6 +60,7 @@ describe('webex-core', () => {
59
60
  services.initialize();
60
61
 
61
62
  // call the onReady callback
63
+ services.listenToOnce.getCall(0).args[2]();
62
64
  services.listenToOnce.getCall(1).args[2]();
63
65
  await waitForAsync();
64
66
 
@@ -81,6 +83,7 @@ describe('webex-core', () => {
81
83
  services.initialize();
82
84
 
83
85
  // call the onReady callback
86
+ services.listenToOnce.getCall(0).args[2]();
84
87
  services.listenToOnce.getCall(1).args[2]();
85
88
 
86
89
  await waitForAsync();
@@ -112,6 +115,7 @@ describe('webex-core', () => {
112
115
  services.initialize();
113
116
 
114
117
  // call the onReady callback
118
+ services.listenToOnce.getCall(0).args[2]();
115
119
  services.listenToOnce.getCall(1).args[2]();
116
120
 
117
121
  await waitForAsync();
@@ -122,6 +126,307 @@ describe('webex-core', () => {
122
126
  `services: failed to init initial services when credentials available, ${expectedMessage}`
123
127
  );
124
128
  });
129
+
130
+ it('sets services.ready to true when waitForCatalogInit is disabled (default)', () => {
131
+ services.listenToOnce = sinon.stub();
132
+ services.initialize();
133
+
134
+ // Fire the change:config handler which decides gated-vs-ungated and, in
135
+ // ungated mode, flips services.ready to true.
136
+ services.listenToOnce.getCall(0).args[2]();
137
+
138
+ assert.isTrue(services.ready, 'services.ready should be true so it does not block webex.ready');
139
+ });
140
+
141
+ it('listens on the "ready" event when waitForCatalogInit is disabled (default)', () => {
142
+ services.listenToOnce = sinon.stub();
143
+ services.initialize();
144
+
145
+ // Fire change:config to trigger the mode-specific listener registration.
146
+ services.listenToOnce.getCall(0).args[2]();
147
+
148
+ // Call 0 is 'change:config'; call 1 is the catalog init listener.
149
+ const [, event] = services.listenToOnce.getCall(1).args;
150
+ assert.equal(event, 'ready', 'default path listens on webex ready');
151
+ });
152
+ });
153
+
154
+ describe('#initialize (waitForCatalogInit=true)', () => {
155
+ beforeEach(() => {
156
+ services.webex.config = {
157
+ ...(services.webex.config || {}),
158
+ services: {waitForCatalogInit: true, catalogInitTimeout: 15000},
159
+ };
160
+ // Reset ready flag: the outer beforeEach constructs services which
161
+ // runs initialize() once with no config (ungated path, flips ready to
162
+ // true). We reset here so tests that call initialize() a second time
163
+ // with the flag enabled observe the gated behavior from a clean state.
164
+ services.ready = false;
165
+ });
166
+
167
+ it('leaves services.ready=false after initialize (does not flip until finalize)', () => {
168
+ services.listenToOnce = sinon.stub();
169
+ services.initialize();
170
+
171
+ assert.isFalse(services.ready, 'services.ready should block webex.ready until finalize');
172
+ });
173
+
174
+ it('listens on the "loaded" event (not "ready") to avoid deadlock', () => {
175
+ services.listenToOnce = sinon.stub();
176
+ services.initialize();
177
+
178
+ // Fire change:config to trigger the mode-specific listener registration.
179
+ services.listenToOnce.getCall(0).args[2]();
180
+
181
+ // Call 0 is 'change:config'; call 1 is the catalog init listener.
182
+ const [, event] = services.listenToOnce.getCall(1).args;
183
+ assert.equal(event, 'loaded', 'gated path must listen on loaded to avoid deadlock');
184
+ });
185
+
186
+ it('flips services.ready=true after init succeeds with credentials', async () => {
187
+ services.listenToOnce = sinon.stub();
188
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
189
+ services.webex.credentials = {
190
+ supertoken: {access_token: 'token'},
191
+ };
192
+
193
+ services.initialize();
194
+ services.listenToOnce.getCall(0).args[2]();
195
+ services.listenToOnce.getCall(1).args[2]();
196
+ await waitForAsync();
197
+
198
+ assert.isTrue(services.ready);
199
+ });
200
+
201
+ it('flips services.ready=true after init succeeds without credentials (preauth path)', async () => {
202
+ services.listenToOnce = sinon.stub();
203
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
204
+
205
+ services.initialize();
206
+ services.listenToOnce.getCall(0).args[2]();
207
+ services.listenToOnce.getCall(1).args[2]();
208
+ await waitForAsync();
209
+
210
+ assert.isTrue(services.ready);
211
+ });
212
+
213
+ it('flips services.ready=true even when initServiceCatalogs errors, so webex.ready can fire', async () => {
214
+ services.listenToOnce = sinon.stub();
215
+ services.initServiceCatalogs = sinon.stub().returns(Promise.reject(new Error('boom')));
216
+ services.webex.credentials = {
217
+ supertoken: {access_token: 'token'},
218
+ };
219
+ services.logger.error = sinon.stub();
220
+
221
+ services.initialize();
222
+ services.listenToOnce.getCall(0).args[2]();
223
+ services.listenToOnce.getCall(1).args[2]();
224
+ await waitForAsync();
225
+
226
+ assert.isTrue(services.initFailed);
227
+ assert.isTrue(services.ready);
228
+ });
229
+
230
+ it('flips services.ready=true even when collectPreauthCatalog errors', async () => {
231
+ services.listenToOnce = sinon.stub();
232
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.reject(new Error('boom')));
233
+ services.logger.error = sinon.stub();
234
+
235
+ services.initialize();
236
+ services.listenToOnce.getCall(0).args[2]();
237
+ services.listenToOnce.getCall(1).args[2]();
238
+ await waitForAsync();
239
+
240
+ assert.isTrue(services.initFailed);
241
+ assert.isTrue(services.ready);
242
+ });
243
+
244
+ it('flips services.ready=true when init times out', async () => {
245
+ const clock = sinon.useFakeTimers();
246
+ services.listenToOnce = sinon.stub();
247
+ // Never resolves - the timeout must win the race.
248
+ services.initServiceCatalogs = sinon.stub().returns(new Promise(() => {}));
249
+ services.webex.credentials = {
250
+ supertoken: {access_token: 'token'},
251
+ };
252
+ services.logger.error = sinon.stub();
253
+
254
+ services.initialize();
255
+ services.listenToOnce.getCall(0).args[2]();
256
+ services.listenToOnce.getCall(1).args[2]();
257
+
258
+ // Advance past the 15s init timeout and flush microtasks so the
259
+ // rejected timeout can propagate through .catch and .finally into
260
+ // _finalizeReady().
261
+ await clock.tickAsync(15_001);
262
+ clock.restore();
263
+
264
+ assert.isTrue(services.initFailed);
265
+ assert.isTrue(services.ready);
266
+ sinon.assert.calledWithMatch(
267
+ services.logger.error,
268
+ /services: init timed out after 15000ms/
269
+ );
270
+ });
271
+
272
+ it('awaits an in-flight credentials refresh before flipping services.ready=true', async () => {
273
+ services.listenToOnce = sinon.stub();
274
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
275
+
276
+ let onChangeIsRefreshing;
277
+ services.webex.credentials = {
278
+ supertoken: {access_token: 'token'},
279
+ isRefreshing: true,
280
+ once: sinon.stub().callsFake((event, cb) => {
281
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
282
+ }),
283
+ };
284
+
285
+ services.initialize();
286
+ services.listenToOnce.getCall(0).args[2]();
287
+ services.listenToOnce.getCall(1).args[2]();
288
+ await waitForAsync();
289
+
290
+ assert.isFalse(services.ready, 'must not flip ready while refresh is in flight');
291
+ assert.isFunction(onChangeIsRefreshing, 'must subscribe to change:isRefreshing');
292
+ sinon.assert.calledWith(services.webex.credentials.once, 'change:isRefreshing');
293
+
294
+ // Simulate refresh completing.
295
+ onChangeIsRefreshing();
296
+ await waitForAsync();
297
+
298
+ assert.isTrue(services.ready);
299
+ });
300
+
301
+ it('registers a change:canAuthorize listener when there is no supertoken (fresh-login path)', async () => {
302
+ services.listenToOnce = sinon.stub();
303
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
304
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
305
+
306
+ services.initialize();
307
+ // initialize() creates a new catalog and replaces the WeakMap entry.
308
+ const currentCatalog = services._getCatalog();
309
+ // Fire the 'loaded' listener (index 1; index 0 was 'change:config').
310
+ services.listenToOnce.getCall(0).args[2]();
311
+ services.listenToOnce.getCall(1).args[2]();
312
+ await waitForAsync();
313
+
314
+ const freshLoginCall = services.listenToOnce
315
+ .getCalls()
316
+ .find((call) => call.args[1] === 'change:canAuthorize');
317
+ assert.isDefined(freshLoginCall, 'expected change:canAuthorize listener to be registered');
318
+
319
+ // Simulate OAuth completing.
320
+ services.webex.canAuthorize = true;
321
+ currentCatalog.status.postauth.ready = false;
322
+ freshLoginCall.args[2]();
323
+
324
+ assert.isTrue(
325
+ services.initServiceCatalogs.called,
326
+ 'expected postauth catalog init after canAuthorize flips'
327
+ );
328
+
329
+ // `catalog.isReady` is now set inside initServiceCatalogs (after the
330
+ // postauth updateServices) rather than by this listener, so it is not
331
+ // asserted here where initServiceCatalogs is stubbed.
332
+ await waitForAsync();
333
+ });
334
+
335
+ it('does not re-init postauth catalog when canAuthorize fires but catalog is already ready', async () => {
336
+ services.listenToOnce = sinon.stub();
337
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
338
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
339
+
340
+ services.initialize();
341
+ // initialize() creates a new catalog; grab the current one.
342
+ const currentCatalog = services._getCatalog();
343
+ services.listenToOnce.getCall(0).args[2]();
344
+ services.listenToOnce.getCall(1).args[2]();
345
+ await waitForAsync();
346
+
347
+ const freshLoginCall = services.listenToOnce
348
+ .getCalls()
349
+ .find((call) => call.args[1] === 'change:canAuthorize');
350
+
351
+ services.webex.canAuthorize = true;
352
+ currentCatalog.status.postauth.ready = true; // already collected
353
+ freshLoginCall.args[2]();
354
+
355
+ assert.isFalse(
356
+ services.initServiceCatalogs.called,
357
+ 'must not re-fetch postauth catalog if it is already ready'
358
+ );
359
+ });
360
+
361
+ it('finalizes ready when cache is warm (skips network fetch)', async () => {
362
+ services.listenToOnce = sinon.stub();
363
+ services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(true));
364
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
365
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
366
+
367
+ services.initialize();
368
+ services.listenToOnce.getCall(0).args[2]();
369
+ services.listenToOnce.getCall(1).args[2]();
370
+ await waitForAsync();
371
+ await waitForAsync();
372
+
373
+ assert.isTrue(services.ready);
374
+ assert.isFalse(
375
+ services.initServiceCatalogs.called,
376
+ 'must skip network fetch when cache is warm'
377
+ );
378
+ assert.isFalse(
379
+ services.collectPreauthCatalog.called,
380
+ 'must skip preauth fetch when cache is warm'
381
+ );
382
+ });
383
+ });
384
+
385
+ describe('#_finalizeReady', () => {
386
+ beforeEach(() => {
387
+ // Outer beforeEach construction already ran initialize() (ungated,
388
+ // sets ready=true). Reset so _finalizeReady's flip is observable.
389
+ services.ready = false;
390
+ });
391
+
392
+ it('sets ready=true immediately when credentials are not refreshing', async () => {
393
+ services.webex.credentials = {isRefreshing: false};
394
+ assert.isFalse(services.ready);
395
+
396
+ await services._finalizeReady();
397
+
398
+ assert.isTrue(services.ready);
399
+ });
400
+
401
+ it('sets ready=true when credentials are missing', async () => {
402
+ services.webex.credentials = undefined;
403
+
404
+ await services._finalizeReady();
405
+
406
+ assert.isTrue(services.ready);
407
+ });
408
+
409
+ it('awaits change:isRefreshing before setting ready=true', async () => {
410
+ let onChangeIsRefreshing;
411
+ services.webex.credentials = {
412
+ isRefreshing: true,
413
+ once: sinon.stub().callsFake((event, cb) => {
414
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
415
+ }),
416
+ };
417
+
418
+ const settled = services._finalizeReady();
419
+ // Let the async body reach its await point.
420
+ await waitForAsync();
421
+
422
+ assert.isFalse(services.ready, 'ready must not flip while refresh is in flight');
423
+ assert.isFunction(onChangeIsRefreshing);
424
+
425
+ onChangeIsRefreshing();
426
+ await settled;
427
+
428
+ assert.isTrue(services.ready);
429
+ });
125
430
  });
126
431
 
127
432
  describe('#initServiceCatalogs', () => {
@@ -1041,7 +1346,7 @@ describe('webex-core', () => {
1041
1346
  sinon.stub(services, 'request').resolves({body: {services: [], activeServices: {}, timestamp: Date.now().toString(), orgId: 'urn:EXAMPLE:org', format: 'U2CV2'}});
1042
1347
  // Cause ready callback to run immediately
1043
1348
  services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
1044
- if (event === 'ready') cb();
1349
+ if (event === 'change:config' || event === 'ready') cb();
1045
1350
  });
1046
1351
 
1047
1352
  // Act
@@ -1083,7 +1388,7 @@ describe('webex-core', () => {
1083
1388
  const cacheSpy = sinon.spy(services, '_cacheCatalog');
1084
1389
  // Cause ready callback to run immediately
1085
1390
  services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
1086
- if (event === 'ready') cb();
1391
+ if (event === 'change:config' || event === 'ready') cb();
1087
1392
  });
1088
1393
 
1089
1394
  // Act