@webex/webex-core 3.11.0-webex-services-ready.1 → 3.12.0-auth-prejoin-fetch.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.
Files changed (49) hide show
  1. package/README.md +5 -2
  2. package/dist/config.js +24 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/credentials-config.js +12 -0
  5. package/dist/credentials-config.js.map +1 -1
  6. package/dist/interceptors/redirect.js +1 -1
  7. package/dist/interceptors/redirect.js.map +1 -1
  8. package/dist/lib/batcher.js +23 -7
  9. package/dist/lib/batcher.js.map +1 -1
  10. package/dist/lib/credentials/credentials.js +48 -4
  11. package/dist/lib/credentials/credentials.js.map +1 -1
  12. package/dist/lib/credentials/token.js +1 -1
  13. package/dist/lib/domains.js +90 -0
  14. package/dist/lib/domains.js.map +1 -0
  15. package/dist/lib/services/service-catalog.js +6 -10
  16. package/dist/lib/services/service-catalog.js.map +1 -1
  17. package/dist/lib/services/services.js +244 -57
  18. package/dist/lib/services/services.js.map +1 -1
  19. package/dist/lib/services-v2/service-catalog.js +6 -12
  20. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  21. package/dist/lib/services-v2/services-v2.js +243 -52
  22. package/dist/lib/services-v2/services-v2.js.map +1 -1
  23. package/dist/plugins/logger.js +1 -1
  24. package/dist/webex-core.js +2 -2
  25. package/dist/webex-core.js.map +1 -1
  26. package/package.json +13 -13
  27. package/src/config.js +27 -0
  28. package/src/credentials-config.js +13 -0
  29. package/src/interceptors/redirect.js +4 -1
  30. package/src/lib/batcher.js +25 -10
  31. package/src/lib/credentials/credentials.js +50 -3
  32. package/src/lib/domains.ts +94 -0
  33. package/src/lib/services/service-catalog.js +6 -10
  34. package/src/lib/services/services.js +218 -46
  35. package/src/lib/services-v2/service-catalog.ts +6 -11
  36. package/src/lib/services-v2/services-v2.ts +218 -42
  37. package/test/fixtures/activation-email.ts +22 -0
  38. package/test/integration/spec/services/service-catalog.js +7 -6
  39. package/test/integration/spec/services/services.js +188 -128
  40. package/test/integration/spec/services-v2/services-v2.js +174 -114
  41. package/test/unit/spec/credentials/credentials.js +133 -2
  42. package/test/unit/spec/interceptors/auth.js +56 -0
  43. package/test/unit/spec/lib/batcher.js +56 -0
  44. package/test/unit/spec/services/service-catalog.js +93 -11
  45. package/test/unit/spec/services/services.js +591 -322
  46. package/test/unit/spec/services-v2/service-catalog.ts +93 -11
  47. package/test/unit/spec/services-v2/services-v2.ts +539 -214
  48. package/test/unit/spec/webex-core.js +0 -2
  49. package/test/unit/spec/webex-internal-core.js +0 -2
@@ -34,53 +34,6 @@ describe('webex-core', () => {
34
34
  });
35
35
 
36
36
  describe('#initialize', () => {
37
- it('listens for "loaded" event instead of "ready" to avoid deadlock', () => {
38
- services.listenToOnce = sinon.stub();
39
- services.initialize();
40
-
41
- // Second listenToOnce call should be for 'loaded' event
42
- assert.equal(services.listenToOnce.getCall(1).args[1], 'loaded');
43
- });
44
-
45
- it('services.ready starts as false', () => {
46
- assert.isFalse(services.ready);
47
- });
48
-
49
- it('sets ready to true and triggers services:initialized when initialization succeeds with credentials', async () => {
50
- services.listenToOnce = sinon.stub();
51
- services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
52
- services.trigger = sinon.stub();
53
- services.webex.credentials = {
54
- supertoken: {
55
- access_token: 'token',
56
- },
57
- };
58
-
59
- services.initialize();
60
-
61
- // call the onLoaded callback
62
- services.listenToOnce.getCall(1).args[2]();
63
- await waitForAsync();
64
-
65
- assert.isTrue(services.ready);
66
- sinon.assert.calledWith(services.trigger, 'services:initialized');
67
- });
68
-
69
- it('sets ready to true and triggers services:initialized when initialization succeeds without credentials', async () => {
70
- services.listenToOnce = sinon.stub();
71
- services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
72
- services.trigger = sinon.stub();
73
-
74
- services.initialize();
75
-
76
- // call the onLoaded callback
77
- services.listenToOnce.getCall(1).args[2]();
78
- await waitForAsync();
79
-
80
- assert.isTrue(services.ready);
81
- sinon.assert.calledWith(services.trigger, 'services:initialized');
82
- });
83
-
84
37
  it('initFailed is false when initialization succeeds and credentials are available', async () => {
85
38
  services.listenToOnce = sinon.stub();
86
39
  services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
@@ -92,7 +45,8 @@ describe('webex-core', () => {
92
45
 
93
46
  services.initialize();
94
47
 
95
- // call the onLoaded callback
48
+ // call the onReady callback
49
+ services.listenToOnce.getCall(0).args[2]();
96
50
  services.listenToOnce.getCall(1).args[2]();
97
51
  await waitForAsync();
98
52
 
@@ -105,7 +59,8 @@ describe('webex-core', () => {
105
59
 
106
60
  services.initialize();
107
61
 
108
- // call the onLoaded callback
62
+ // call the onReady callback
63
+ services.listenToOnce.getCall(0).args[2]();
109
64
  services.listenToOnce.getCall(1).args[2]();
110
65
  await waitForAsync();
111
66
 
@@ -116,7 +71,7 @@ describe('webex-core', () => {
116
71
  {error: new Error('failed'), expectedMessage: 'failed'},
117
72
  {error: undefined, expectedMessage: undefined},
118
73
  ])(
119
- 'sets initFailed to true when collectPreauthCatalog errors but still sets ready to true',
74
+ 'sets initFailed to true when collectPreauthCatalog errors',
120
75
  async ({error, expectedMessage}) => {
121
76
  services.collectPreauthCatalog = sinon.stub().callsFake(() => {
122
77
  return Promise.reject(error);
@@ -124,18 +79,16 @@ describe('webex-core', () => {
124
79
 
125
80
  services.listenToOnce = sinon.stub();
126
81
  services.logger.error = sinon.stub();
127
- services.trigger = sinon.stub();
128
82
 
129
83
  services.initialize();
130
84
 
131
- // call the onLoaded callback
85
+ // call the onReady callback
86
+ services.listenToOnce.getCall(0).args[2]();
132
87
  services.listenToOnce.getCall(1).args[2]();
133
88
 
134
89
  await waitForAsync();
135
90
 
136
91
  assert.isTrue(services.initFailed);
137
- assert.isTrue(services.ready);
138
- sinon.assert.calledWith(services.trigger, 'services:initialized');
139
92
  sinon.assert.calledWith(
140
93
  services.logger.error,
141
94
  `services: failed to init initial services when no credentials available, ${expectedMessage}`
@@ -147,7 +100,7 @@ describe('webex-core', () => {
147
100
  {error: new Error('failed'), expectedMessage: 'failed'},
148
101
  {error: undefined, expectedMessage: undefined},
149
102
  ])(
150
- 'sets initFailed to true when initServiceCatalogs errors but still sets ready to true',
103
+ 'sets initFailed to true when initServiceCatalogs errors',
151
104
  async ({error, expectedMessage}) => {
152
105
  services.initServiceCatalogs = sinon.stub().callsFake(() => {
153
106
  return Promise.reject(error);
@@ -160,18 +113,16 @@ describe('webex-core', () => {
160
113
 
161
114
  services.listenToOnce = sinon.stub();
162
115
  services.logger.error = sinon.stub();
163
- services.trigger = sinon.stub();
164
116
 
165
117
  services.initialize();
166
118
 
167
- // call the onLoaded callback
119
+ // call the onReady callback
120
+ services.listenToOnce.getCall(0).args[2]();
168
121
  services.listenToOnce.getCall(1).args[2]();
169
122
 
170
123
  await waitForAsync();
171
124
 
172
125
  assert.isTrue(services.initFailed);
173
- assert.isTrue(services.ready);
174
- sinon.assert.calledWith(services.trigger, 'services:initialized');
175
126
  sinon.assert.calledWith(
176
127
  services.logger.error,
177
128
  `services: failed to init initial services when credentials available, ${expectedMessage}`
@@ -179,98 +130,431 @@ describe('webex-core', () => {
179
130
  }
180
131
  );
181
132
 
182
- describe('when change:canAuthorize fires', () => {
183
- it('calls initServiceCatalogs when canAuthorize becomes true and postauth catalog is not ready', async () => {
184
- services.listenToOnce = sinon.stub();
185
- services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(false));
186
- services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
187
- services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
188
- services.trigger = sinon.stub();
189
- // Ensure no credentials so we go to the preauth path
190
- services.webex.credentials = {};
133
+ it('sets services.ready to true when waitForCatalogInit is disabled (default)', () => {
134
+ services.listenToOnce = sinon.stub();
135
+ services.initialize();
191
136
 
192
- services.initialize();
137
+ // Fire the change:config handler which decides gated-vs-ungated and, in
138
+ // ungated mode, flips services.ready to true.
139
+ services.listenToOnce.getCall(0).args[2]();
193
140
 
194
- // Get the catalog after initialize creates it
195
- const testCatalog = services._getCatalog();
196
- testCatalog.status.postauth.ready = false;
141
+ assert.isTrue(services.ready, 'services.ready should be true so it does not block webex.ready');
142
+ });
197
143
 
198
- // call the onLoaded callback (preauth path)
199
- services.listenToOnce.getCall(1).args[2]();
200
- await waitForAsync();
144
+ it('listens on the "ready" event when waitForCatalogInit is disabled (default)', () => {
145
+ services.listenToOnce = sinon.stub();
146
+ services.initialize();
201
147
 
202
- // Now set canAuthorize to true to simulate auth completing
203
- services.webex.canAuthorize = true;
148
+ // Fire change:config to trigger the mode-specific listener registration.
149
+ services.listenToOnce.getCall(0).args[2]();
204
150
 
205
- // call the change:canAuthorize callback (should be call 2)
206
- services.listenToOnce.getCall(2).args[2]();
207
- await waitForAsync();
151
+ const [, event] = services.listenToOnce.getCall(1).args;
152
+ assert.equal(event, 'ready', 'default path listens on webex ready');
153
+ });
208
154
 
209
- sinon.assert.calledOnce(services.initServiceCatalogs);
210
- assert.isTrue(testCatalog.isReady);
211
- });
155
+ it('skips preauth catalog collection when skipPreauthCatalogOnUnauthenticated is true and no credentials', async () => {
156
+ services.webex.config = {
157
+ ...(services.webex.config || {}),
158
+ services: {skipPreauthCatalogOnUnauthenticated: true},
159
+ };
160
+ services.listenToOnce = sinon.stub();
161
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
162
+ services.logger.info = sinon.stub();
212
163
 
213
- it('does not call initServiceCatalogs when postauth catalog is already ready', async () => {
214
- services.listenToOnce = sinon.stub();
215
- services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(false));
216
- services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
217
- services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
218
- services.trigger = sinon.stub();
219
- // Ensure no credentials so we go to the preauth path
220
- services.webex.credentials = {};
164
+ services.initialize();
165
+ services.listenToOnce.getCall(0).args[2]();
166
+ services.listenToOnce.getCall(1).args[2]();
167
+ await waitForAsync();
221
168
 
222
- services.initialize();
169
+ assert.isFalse(
170
+ services.collectPreauthCatalog.called,
171
+ 'must not collect preauth catalog while unauthenticated when option is enabled'
172
+ );
173
+ assert.isFalse(services.initFailed);
174
+ sinon.assert.calledWith(
175
+ services.logger.info,
176
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
177
+ );
178
+ });
223
179
 
224
- // Get the catalog after initialize creates it
225
- const testCatalog = services._getCatalog();
180
+ it('still collects preauth catalog when skipPreauthCatalogOnUnauthenticated is false (default)', async () => {
181
+ services.webex.config = {
182
+ ...(services.webex.config || {}),
183
+ services: {skipPreauthCatalogOnUnauthenticated: false},
184
+ };
185
+ services.listenToOnce = sinon.stub();
186
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
226
187
 
227
- // call the onLoaded callback (preauth path)
228
- services.listenToOnce.getCall(1).args[2]();
229
- await waitForAsync();
188
+ services.initialize();
189
+ services.listenToOnce.getCall(0).args[2]();
190
+ services.listenToOnce.getCall(1).args[2]();
191
+ await waitForAsync();
230
192
 
231
- // Set canAuthorize to true and postauth as ready BEFORE calling the callback
232
- services.webex.canAuthorize = true;
233
- testCatalog.status.postauth.ready = true;
193
+ assert.isTrue(
194
+ services.collectPreauthCatalog.called,
195
+ 'must collect preauth catalog when option is disabled'
196
+ );
197
+ });
198
+ });
234
199
 
235
- // call the change:canAuthorize callback (should be call 2)
236
- services.listenToOnce.getCall(2).args[2]();
237
- await waitForAsync();
200
+ describe('#initialize (waitForCatalogInit=true)', () => {
201
+ let clock;
238
202
 
239
- sinon.assert.notCalled(services.initServiceCatalogs);
240
- });
203
+ beforeEach(() => {
204
+ services.webex.config = {
205
+ ...(services.webex.config || {}),
206
+ services: {waitForCatalogInit: true, catalogInitTimeout: 15000},
207
+ };
208
+ // Reset ready flag: the outer beforeEach constructs services which
209
+ // runs initialize() once with no config (ungated path, flips ready to
210
+ // true). We reset here so tests that call initialize() a second time
211
+ // with the flag enabled observe the gated behavior from a clean state.
212
+ services.ready = false;
213
+ // The gated path schedules a real 15s setTimeout via _makeInitTimeout()
214
+ // that is not cleared when the init race resolves. Fake only setTimeout/
215
+ // clearTimeout (leaving setImmediate real so waitForAsync still works)
216
+ // so restoring in afterEach clears the pending timer and Jest can exit.
217
+ clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
218
+ });
241
219
 
242
- it('logs an error when initServiceCatalogs fails after auth', async () => {
243
- services.listenToOnce = sinon.stub();
244
- services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(false));
245
- services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
246
- services.initServiceCatalogs = sinon.stub().rejects(new Error('auth failed'));
247
- services.logger.error = sinon.stub();
248
- services.trigger = sinon.stub();
249
- // Ensure no credentials so we go to the preauth path
250
- services.webex.credentials = {};
220
+ afterEach(() => {
221
+ clock.restore();
222
+ });
251
223
 
252
- services.initialize();
224
+ it('leaves services.ready=false after initialize (does not flip until finalize)', () => {
225
+ services.listenToOnce = sinon.stub();
226
+ services.initialize();
253
227
 
254
- // Get the catalog after initialize creates it
255
- const testCatalog = services._getCatalog();
256
- testCatalog.status.postauth.ready = false;
228
+ assert.isFalse(services.ready, 'services.ready should block webex.ready until finalize');
229
+ });
257
230
 
258
- // call the onLoaded callback (preauth path)
259
- services.listenToOnce.getCall(1).args[2]();
260
- await waitForAsync();
231
+ it('listens on the "loaded" event (not "ready") to avoid deadlock', () => {
232
+ services.listenToOnce = sinon.stub();
233
+ services.initialize();
261
234
 
262
- // Now set canAuthorize to true to simulate auth completing
263
- services.webex.canAuthorize = true;
235
+ // Fire change:config to trigger the mode-specific listener registration.
236
+ services.listenToOnce.getCall(0).args[2]();
264
237
 
265
- // call the change:canAuthorize callback (should be call 2)
266
- services.listenToOnce.getCall(2).args[2]();
267
- await waitForAsync();
238
+ const [, event] = services.listenToOnce.getCall(1).args;
239
+ assert.equal(event, 'loaded', 'gated path must listen on loaded to avoid deadlock');
240
+ });
268
241
 
269
- sinon.assert.calledWith(
270
- services.logger.error,
271
- 'services: failed to init service catalogs after auth, auth failed'
272
- );
273
- });
242
+ it('flips services.ready=true after init succeeds with credentials', async () => {
243
+ services.listenToOnce = sinon.stub();
244
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
245
+ services.webex.credentials = {
246
+ supertoken: {access_token: 'token'},
247
+ };
248
+
249
+ services.initialize();
250
+ services.listenToOnce.getCall(0).args[2]();
251
+ services.listenToOnce.getCall(1).args[2]();
252
+ await waitForAsync();
253
+
254
+ assert.isTrue(services.ready);
255
+ });
256
+
257
+ it('flips services.ready=true after init succeeds without credentials (preauth path)', async () => {
258
+ services.listenToOnce = sinon.stub();
259
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
260
+
261
+ services.initialize();
262
+ services.listenToOnce.getCall(0).args[2]();
263
+ services.listenToOnce.getCall(1).args[2]();
264
+ await waitForAsync();
265
+
266
+ assert.isTrue(services.ready);
267
+ });
268
+
269
+ it('flips services.ready=true even when initServiceCatalogs errors, so webex.ready can fire', async () => {
270
+ services.listenToOnce = sinon.stub();
271
+ services.initServiceCatalogs = sinon.stub().returns(Promise.reject(new Error('boom')));
272
+ services.webex.credentials = {
273
+ supertoken: {access_token: 'token'},
274
+ };
275
+ services.logger.error = sinon.stub();
276
+
277
+ services.initialize();
278
+ services.listenToOnce.getCall(0).args[2]();
279
+ services.listenToOnce.getCall(1).args[2]();
280
+ await waitForAsync();
281
+
282
+ assert.isTrue(services.initFailed);
283
+ assert.isTrue(services.ready);
284
+ });
285
+
286
+ it('flips services.ready=true even when collectPreauthCatalog errors', async () => {
287
+ services.listenToOnce = sinon.stub();
288
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.reject(new Error('boom')));
289
+ services.logger.error = sinon.stub();
290
+
291
+ services.initialize();
292
+ services.listenToOnce.getCall(0).args[2]();
293
+ services.listenToOnce.getCall(1).args[2]();
294
+ await waitForAsync();
295
+
296
+ assert.isTrue(services.initFailed);
297
+ assert.isTrue(services.ready);
298
+ });
299
+
300
+ it('flips services.ready=true when init times out', async () => {
301
+ services.listenToOnce = sinon.stub();
302
+ services.initServiceCatalogs = sinon.stub().returns(new Promise(() => {}));
303
+ services.webex.credentials = {
304
+ supertoken: {access_token: 'token'},
305
+ };
306
+ services.logger.error = sinon.stub();
307
+
308
+ services.initialize();
309
+ services.listenToOnce.getCall(0).args[2]();
310
+ services.listenToOnce.getCall(1).args[2]();
311
+
312
+ await clock.tickAsync(15_001);
313
+
314
+ assert.isTrue(services.initFailed);
315
+ assert.isTrue(services.ready);
316
+ sinon.assert.calledWithMatch(
317
+ services.logger.error,
318
+ /services: init timed out after 15000ms/
319
+ );
320
+ });
321
+
322
+ it('awaits an in-flight credentials refresh before flipping services.ready=true', async () => {
323
+ services.listenToOnce = sinon.stub();
324
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
325
+
326
+ let onChangeIsRefreshing: (() => void) | undefined;
327
+ services.webex.credentials = {
328
+ supertoken: {access_token: 'token'},
329
+ isRefreshing: true,
330
+ once: sinon.stub().callsFake((event: string, cb: () => void) => {
331
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
332
+ }),
333
+ };
334
+
335
+ services.initialize();
336
+ services.listenToOnce.getCall(0).args[2]();
337
+ services.listenToOnce.getCall(1).args[2]();
338
+ await waitForAsync();
339
+
340
+ assert.isFalse(services.ready, 'must not flip ready while refresh is in flight');
341
+ assert.isFunction(onChangeIsRefreshing, 'must subscribe to change:isRefreshing');
342
+ sinon.assert.calledWith(services.webex.credentials.once, 'change:isRefreshing');
343
+
344
+ onChangeIsRefreshing!();
345
+ await waitForAsync();
346
+
347
+ assert.isTrue(services.ready);
348
+ });
349
+
350
+ it('registers a change:canAuthorize listener when there is no supertoken (fresh-login path)', async () => {
351
+ services.listenToOnce = sinon.stub();
352
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
353
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
354
+
355
+ services.initialize();
356
+ // initialize() creates a new catalog and replaces the WeakMap entry.
357
+ const currentCatalog = services._getCatalog();
358
+ services.listenToOnce.getCall(0).args[2]();
359
+ services.listenToOnce.getCall(1).args[2]();
360
+ await waitForAsync();
361
+
362
+ const freshLoginCall = services.listenToOnce
363
+ .getCalls()
364
+ .find((call: any) => call.args[1] === 'change:canAuthorize');
365
+ assert.isDefined(freshLoginCall, 'expected change:canAuthorize listener to be registered');
366
+
367
+ services.webex.canAuthorize = true;
368
+ currentCatalog.status.postauth.ready = false;
369
+ freshLoginCall!.args[2]();
370
+
371
+ assert.isTrue(
372
+ services.initServiceCatalogs.called,
373
+ 'expected postauth catalog init after canAuthorize flips'
374
+ );
375
+
376
+ // `catalog.isReady` is now set inside initServiceCatalogs (after the
377
+ // postauth updateServices) rather than by this listener, so it is not
378
+ // asserted here where initServiceCatalogs is stubbed.
379
+ await waitForAsync();
380
+ });
381
+
382
+ it('does not re-init postauth catalog when canAuthorize fires but catalog is already ready', async () => {
383
+ services.listenToOnce = sinon.stub();
384
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
385
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
386
+
387
+ services.initialize();
388
+ // initialize() creates a new catalog; grab the current one.
389
+ const currentCatalog = services._getCatalog();
390
+ services.listenToOnce.getCall(0).args[2]();
391
+ services.listenToOnce.getCall(1).args[2]();
392
+ await waitForAsync();
393
+
394
+ const freshLoginCall = services.listenToOnce
395
+ .getCalls()
396
+ .find((call: any) => call.args[1] === 'change:canAuthorize');
397
+
398
+ services.webex.canAuthorize = true;
399
+ currentCatalog.status.postauth.ready = true;
400
+ freshLoginCall!.args[2]();
401
+
402
+ assert.isFalse(
403
+ services.initServiceCatalogs.called,
404
+ 'must not re-fetch postauth catalog if it is already ready'
405
+ );
406
+ });
407
+
408
+ it('finalizes ready when cache is warm (skips network fetch)', async () => {
409
+ services.listenToOnce = sinon.stub();
410
+ services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(true));
411
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
412
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
413
+
414
+ services.initialize();
415
+ services.listenToOnce.getCall(0).args[2]();
416
+ services.listenToOnce.getCall(1).args[2]();
417
+ await waitForAsync();
418
+ await waitForAsync();
419
+
420
+ assert.isTrue(services.ready);
421
+ assert.isFalse(
422
+ services.initServiceCatalogs.called,
423
+ 'must skip network fetch when cache is warm'
424
+ );
425
+ assert.isFalse(
426
+ services.collectPreauthCatalog.called,
427
+ 'must skip preauth fetch when cache is warm'
428
+ );
429
+ });
430
+ });
431
+
432
+ describe('#initialize (waitForCatalogInit=true, skipPreauthCatalogOnUnauthenticated=true)', () => {
433
+ beforeEach(() => {
434
+ services.webex.config = {
435
+ ...(services.webex.config || {}),
436
+ services: {
437
+ waitForCatalogInit: true,
438
+ catalogInitTimeout: 15000,
439
+ skipPreauthCatalogOnUnauthenticated: true,
440
+ },
441
+ };
442
+ services.ready = false;
443
+ });
444
+
445
+ it('skips preauth catalog collection but still finalizes services.ready when unauthenticated', async () => {
446
+ services.listenToOnce = sinon.stub();
447
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
448
+ services.logger.info = sinon.stub();
449
+
450
+ services.initialize();
451
+ services.listenToOnce.getCall(0).args[2]();
452
+ services.listenToOnce.getCall(1).args[2]();
453
+ await waitForAsync();
454
+
455
+ assert.isFalse(
456
+ services.collectPreauthCatalog.called,
457
+ 'must not collect preauth catalog while unauthenticated when option is enabled'
458
+ );
459
+ assert.isTrue(services.ready, 'must still finalize ready so webex.ready is not stalled');
460
+ sinon.assert.calledWith(
461
+ services.logger.info,
462
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
463
+ );
464
+ });
465
+
466
+ it('still registers a change:canAuthorize listener when skipping preauth (fresh-login path)', async () => {
467
+ services.listenToOnce = sinon.stub();
468
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
469
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
470
+
471
+ services.initialize();
472
+ const currentCatalog = services._getCatalog();
473
+ services.listenToOnce.getCall(0).args[2]();
474
+ services.listenToOnce.getCall(1).args[2]();
475
+ await waitForAsync();
476
+
477
+ const freshLoginCall = services.listenToOnce
478
+ .getCalls()
479
+ .find((call: any) => call.args[1] === 'change:canAuthorize');
480
+ assert.isDefined(freshLoginCall, 'expected change:canAuthorize listener to be registered');
481
+
482
+ services.webex.canAuthorize = true;
483
+ currentCatalog.status.postauth.ready = false;
484
+ freshLoginCall!.args[2]();
485
+
486
+ assert.isTrue(
487
+ services.initServiceCatalogs.called,
488
+ 'expected postauth catalog init after canAuthorize flips'
489
+ );
490
+ await waitForAsync();
491
+ });
492
+
493
+ it('does not schedule an init timeout when skipping preauth (no stray timer/rejection)', async () => {
494
+ const clock = sinon.useFakeTimers();
495
+ services.listenToOnce = sinon.stub();
496
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
497
+ services.logger.error = sinon.stub();
498
+
499
+ services.initialize();
500
+ services.listenToOnce.getCall(0).args[2]();
501
+ services.listenToOnce.getCall(1).args[2]();
502
+
503
+ // Advance well past the configured init timeout; since no timeout was
504
+ // scheduled in skip mode, no timeout error should ever be logged.
505
+ await clock.tickAsync(30_000);
506
+ clock.restore();
507
+
508
+ sinon.assert.neverCalledWithMatch(
509
+ services.logger.error,
510
+ /services: init timed out/
511
+ );
512
+ });
513
+ });
514
+
515
+ describe('#_finalizeReady', () => {
516
+ beforeEach(() => {
517
+ // Outer beforeEach construction already ran initialize() (ungated,
518
+ // sets ready=true). Reset so _finalizeReady's flip is observable.
519
+ services.ready = false;
520
+ });
521
+
522
+ it('sets ready=true immediately when credentials are not refreshing', async () => {
523
+ services.webex.credentials = {isRefreshing: false};
524
+ assert.isFalse(services.ready);
525
+
526
+ await services._finalizeReady();
527
+
528
+ assert.isTrue(services.ready);
529
+ });
530
+
531
+ it('sets ready=true when credentials are missing', async () => {
532
+ services.webex.credentials = undefined;
533
+
534
+ await services._finalizeReady();
535
+
536
+ assert.isTrue(services.ready);
537
+ });
538
+
539
+ it('awaits change:isRefreshing before setting ready=true', async () => {
540
+ let onChangeIsRefreshing: (() => void) | undefined;
541
+ services.webex.credentials = {
542
+ isRefreshing: true,
543
+ once: sinon.stub().callsFake((event: string, cb: () => void) => {
544
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
545
+ }),
546
+ };
547
+
548
+ const settled = services._finalizeReady();
549
+ await waitForAsync();
550
+
551
+ assert.isFalse(services.ready, 'ready must not flip while refresh is in flight');
552
+ assert.isFunction(onChangeIsRefreshing);
553
+
554
+ onChangeIsRefreshing!();
555
+ await settled;
556
+
557
+ assert.isTrue(services.ready);
274
558
  });
275
559
  });
276
560
 
@@ -425,7 +709,7 @@ describe('webex-core', () => {
425
709
 
426
710
  services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
427
711
  services.webex.credentials = {
428
- getOrgId: sinon.stub().returns(''),
712
+ getOrgId: sinon.stub().returns('')
429
713
  };
430
714
  catalog.status = {};
431
715
  });
@@ -466,7 +750,7 @@ describe('webex-core', () => {
466
750
  const serviceGroup = 'postauth';
467
751
  const hostmap = {services: [{hostmap: 'hostmap'}]};
468
752
 
469
- services._formatReceivedHostmap = sinon.stub().returns({services: [{some: 'hostmap'}]});
753
+ services._formatReceivedHostmap = sinon.stub().returns({services : [{some: 'hostmap'}]});
470
754
 
471
755
  catalog.updateServiceGroups = sinon.stub().returns(Promise.resolve([{some: 'value'}]));
472
756
 
@@ -482,7 +766,7 @@ describe('webex-core', () => {
482
766
  const serviceGroup = 'postauth';
483
767
  const hostmap = {};
484
768
 
485
- services._formatReceivedHostmap = sinon.stub().returns({services: undefined});
769
+ services._formatReceivedHostmap = sinon.stub().returns({services : undefined});
486
770
 
487
771
  catalog.updateServiceGroups = sinon.stub().returns(Promise.resolve([{some: 'value'}]));
488
772
 
@@ -527,6 +811,22 @@ describe('webex-core', () => {
527
811
  );
528
812
  });
529
813
 
814
+ it('includes the catalog override query when configured', async () => {
815
+ services.webex.config.services = {useCatalogOverride: true};
816
+ sinon.stub(services, '_formatReceivedHostmap').resolves('map response');
817
+ sinon.stub(services, 'request').resolves({});
818
+
819
+ await services._fetchNewServiceHostmap({from: 'limited'});
820
+
821
+ assert.calledOnceWithExactly(services.request, {
822
+ method: 'GET',
823
+ service: 'u2c',
824
+ resource: '/limited/catalog',
825
+ qs: {format: 'U2CV2', useCatalogOverride: true},
826
+ headers: {},
827
+ });
828
+ });
829
+
530
830
  it('checks service request rejects', async () => {
531
831
  const error = new Error('some error');
532
832
 
@@ -721,13 +1021,13 @@ describe('webex-core', () => {
721
1021
  });
722
1022
 
723
1023
  describe('#invalidateCache', () => {
724
- beforeEach(() => {
1024
+ beforeEach( () => {
725
1025
  services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
726
1026
  services.webex.credentials = {
727
- getOrgId: sinon.stub().returns(''),
1027
+ getOrgId: sinon.stub().returns('')
728
1028
  };
729
1029
  catalog.status = {};
730
- });
1030
+ })
731
1031
  it('should log the timestamp parameter', async () => {
732
1032
  const timestamp = '1234567890';
733
1033
  services.logger.info = sinon.stub();
@@ -735,11 +1035,7 @@ describe('webex-core', () => {
735
1035
 
736
1036
  await services.invalidateCache(timestamp);
737
1037
 
738
- assert.calledWith(
739
- services.logger.info,
740
- 'services: invalidate cache, timestamp:',
741
- timestamp
742
- );
1038
+ assert.calledWith(services.logger.info, 'services: invalidate cache, timestamp:', timestamp);
743
1039
  });
744
1040
 
745
1041
  it('should call initServiceCatalogs when invalidate timestamp is newer than catalog timestamp', async () => {
@@ -866,30 +1162,30 @@ describe('webex-core', () => {
866
1162
  // Arrange: seed internal _services with mobius (including duplicate baseUrl)
867
1163
  services._services = [
868
1164
  {
869
- id: 'urn:TEAM:us-east-2_a:mobius',
870
- serviceName: 'mobius',
871
- serviceUrls: [
872
- {baseUrl: 'https://mobius-us-east-2.prod.infra.webex.com/api/v1', priority: 5},
873
- {baseUrl: 'https://mobius-eu-central-1.prod.infra.webex.com/api/v1', priority: 10},
874
- {baseUrl: 'https://mobius-ap-southeast-2.prod.infra.webex.com/api/v1', priority: 15}, // duplicate
1165
+ "id": "urn:TEAM:us-east-2_a:mobius",
1166
+ "serviceName": 'mobius',
1167
+ "serviceUrls": [
1168
+ {"baseUrl": 'https://mobius-us-east-2.prod.infra.webex.com/api/v1', "priority": 5},
1169
+ {"baseUrl": 'https://mobius-eu-central-1.prod.infra.webex.com/api/v1', "priority": 10},
1170
+ {"baseUrl": 'https://mobius-ap-southeast-2.prod.infra.webex.com/api/v1', "priority": 15}, // duplicate
875
1171
  ],
876
1172
  },
877
1173
  {
878
- id: 'urn:TEAM:ap-southeast-2_m:mobius',
879
- serviceName: 'mobius',
880
- serviceUrls: [
881
- {
882
- baseUrl: 'https://mobius-me-central-1.prod.infra.webex.com/api/v1',
883
- priority: 5,
884
- },
885
- {
886
- baseUrl: 'https://mobius-eu-central-1.prod.infra.webex.com/api/v1',
887
- priority: 10,
888
- },
889
- {
890
- baseUrl: 'https://mobius-ap-southeast-2.prod.infra.webex.com/api/v1',
891
- priority: 15,
892
- },
1174
+ "id": "urn:TEAM:ap-southeast-2_m:mobius",
1175
+ "serviceName": "mobius",
1176
+ "serviceUrls": [
1177
+ {
1178
+ "baseUrl": "https://mobius-me-central-1.prod.infra.webex.com/api/v1",
1179
+ "priority": 5
1180
+ },
1181
+ {
1182
+ "baseUrl": "https://mobius-eu-central-1.prod.infra.webex.com/api/v1",
1183
+ "priority": 10
1184
+ },
1185
+ {
1186
+ "baseUrl": "https://mobius-ap-southeast-2.prod.infra.webex.com/api/v1",
1187
+ "priority": 15
1188
+ },
893
1189
  ],
894
1190
  },
895
1191
  // Non-mobius service should be ignored by getMobiusClusters
@@ -907,64 +1203,42 @@ describe('webex-core', () => {
907
1203
  assert.deepEqual(
908
1204
  clusters.map(({host, id, ttl, priority}) => ({host, id, ttl, priority})),
909
1205
  [
910
- {
911
- host: 'mobius-us-east-2.prod.infra.webex.com',
912
- id: 'urn:TEAM:us-east-2_a:mobius',
913
- ttl: 0,
914
- priority: 5,
915
- },
916
- {
917
- host: 'mobius-eu-central-1.prod.infra.webex.com',
918
- id: 'urn:TEAM:us-east-2_a:mobius',
919
- ttl: 0,
920
- priority: 10,
921
- },
922
- {
923
- host: 'mobius-ap-southeast-2.prod.infra.webex.com',
924
- id: 'urn:TEAM:us-east-2_a:mobius',
925
- ttl: 0,
926
- priority: 15,
927
- },
928
- {
929
- host: 'mobius-me-central-1.prod.infra.webex.com',
930
- id: 'urn:TEAM:ap-southeast-2_m:mobius',
931
- ttl: 0,
932
- priority: 5,
933
- },
1206
+ {host: 'mobius-us-east-2.prod.infra.webex.com', id: 'urn:TEAM:us-east-2_a:mobius', ttl: 0, priority: 5},
1207
+ {host: 'mobius-eu-central-1.prod.infra.webex.com', id: 'urn:TEAM:us-east-2_a:mobius', ttl: 0, priority: 10},
1208
+ {host: 'mobius-ap-southeast-2.prod.infra.webex.com', id: 'urn:TEAM:us-east-2_a:mobius', ttl: 0, priority: 15},
1209
+ {host: 'mobius-me-central-1.prod.infra.webex.com', id: 'urn:TEAM:ap-southeast-2_m:mobius', ttl: 0, priority: 5},
934
1210
  ]
935
1211
  );
936
1212
  });
937
1213
  });
938
-
1214
+
939
1215
  describe('#isValidHost', () => {
940
1216
  beforeEach(() => {
941
1217
  // Setting up a mock services list
942
- services._services = [
943
- {
944
- id: 'urn:IDENTITY:PC75:adminAudit',
945
- serviceName: 'adminAudit',
946
- serviceUrls: [
947
- {
948
- baseUrl: 'https://audit-ci-r.wbx2.com/audit-ci/api/v2',
949
- priority: 5,
950
- },
951
- {
952
- baseUrl: 'https://audit-ci-t.wbx2.com/audit-ci/api/v2',
953
- priority: 10,
954
- },
955
- ],
956
- },
957
- {
958
- id: 'urn:IDENTITY:PC75:cdf',
959
- serviceName: 'cdf',
960
- serviceUrls: [
961
- {
962
- baseUrl: 'https://wapdavis.webex.com/davis/api/v1',
963
- priority: 5,
964
- },
965
- ],
966
- },
967
- ];
1218
+ services._services = [{
1219
+ "id": "urn:IDENTITY:PC75:adminAudit",
1220
+ "serviceName": "adminAudit",
1221
+ "serviceUrls": [
1222
+ {
1223
+ "baseUrl": "https://audit-ci-r.wbx2.com/audit-ci/api/v2",
1224
+ "priority": 5
1225
+ },
1226
+ {
1227
+ "baseUrl": "https://audit-ci-t.wbx2.com/audit-ci/api/v2",
1228
+ "priority": 10
1229
+ }
1230
+ ]
1231
+ },
1232
+ {
1233
+ "id": "urn:IDENTITY:PC75:cdf",
1234
+ "serviceName": "cdf",
1235
+ "serviceUrls": [
1236
+ {
1237
+ "baseUrl": "https://wapdavis.webex.com/davis/api/v1",
1238
+ "priority": 5
1239
+ }
1240
+ ]
1241
+ }];
968
1242
  });
969
1243
  afterAll(() => {
970
1244
  // Clean up the mock services list
@@ -988,6 +1262,57 @@ describe('webex-core', () => {
988
1262
  });
989
1263
  });
990
1264
 
1265
+ describe('#isIntegrationEnvironment', () => {
1266
+ it('returns true when u2c URL contains "intb"', () => {
1267
+ services.webex.config = {
1268
+ services: {
1269
+ discovery: {
1270
+ u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1',
1271
+ },
1272
+ },
1273
+ };
1274
+ assert.isTrue(services.isIntegrationEnvironment());
1275
+ });
1276
+
1277
+ it('returns false when u2c URL does not contain "intb" (production)', () => {
1278
+ services.webex.config = {
1279
+ services: {
1280
+ discovery: {
1281
+ u2c: 'https://u2c.wbx2.com/u2c/api/v1',
1282
+ },
1283
+ },
1284
+ };
1285
+ assert.isFalse(services.isIntegrationEnvironment());
1286
+ });
1287
+
1288
+ it('returns false when u2c URL is for FedRAMP', () => {
1289
+ services.webex.config = {
1290
+ services: {
1291
+ discovery: {
1292
+ u2c: 'https://u2c.gov.ciscospark.com/u2c/api/v1',
1293
+ },
1294
+ },
1295
+ };
1296
+ assert.isFalse(services.isIntegrationEnvironment());
1297
+ });
1298
+
1299
+ it('returns false when u2c URL is undefined', () => {
1300
+ services.webex.config = {
1301
+ services: {
1302
+ discovery: {
1303
+ u2c: undefined,
1304
+ },
1305
+ },
1306
+ };
1307
+ assert.isFalse(services.isIntegrationEnvironment());
1308
+ });
1309
+
1310
+ it('returns false when config is not available', () => {
1311
+ services.webex.config = undefined;
1312
+ assert.isFalse(services.isIntegrationEnvironment());
1313
+ });
1314
+ });
1315
+
991
1316
  describe('U2C catalog cache behavior (v2)', () => {
992
1317
  const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap';
993
1318
  let windowBackup;