@webex/webex-core 3.12.0-task-refactor.1 → 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.
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 +501 -96
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +426 -42
  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 +368 -14
  30. package/src/lib/services-v2/services-v2.ts +353 -10
  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 +38 -11
  34. package/test/integration/spec/services-v2/services-v2.js +29 -8
  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 +680 -80
  39. package/test/unit/spec/services-v2/services-v2.ts +484 -62
  40. package/test/unit/spec/webex-core.js +2 -0
  41. package/test/unit/spec/webex-internal-core.js +2 -0
@@ -34,6 +34,53 @@ 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
+
37
84
  it('initFailed is false when initialization succeeds and credentials are available', async () => {
38
85
  services.listenToOnce = sinon.stub();
39
86
  services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
@@ -45,7 +92,7 @@ describe('webex-core', () => {
45
92
 
46
93
  services.initialize();
47
94
 
48
- // call the onReady callback
95
+ // call the onLoaded callback
49
96
  services.listenToOnce.getCall(1).args[2]();
50
97
  await waitForAsync();
51
98
 
@@ -58,7 +105,7 @@ describe('webex-core', () => {
58
105
 
59
106
  services.initialize();
60
107
 
61
- // call the onReady callback
108
+ // call the onLoaded callback
62
109
  services.listenToOnce.getCall(1).args[2]();
63
110
  await waitForAsync();
64
111
 
@@ -67,9 +114,9 @@ describe('webex-core', () => {
67
114
 
68
115
  it.each([
69
116
  {error: new Error('failed'), expectedMessage: 'failed'},
70
- {error: undefined, expectedMessage: undefined}
117
+ {error: undefined, expectedMessage: undefined},
71
118
  ])(
72
- 'sets initFailed to true when collectPreauthCatalog errors',
119
+ 'sets initFailed to true when collectPreauthCatalog errors but still sets ready to true',
73
120
  async ({error, expectedMessage}) => {
74
121
  services.collectPreauthCatalog = sinon.stub().callsFake(() => {
75
122
  return Promise.reject(error);
@@ -77,15 +124,18 @@ describe('webex-core', () => {
77
124
 
78
125
  services.listenToOnce = sinon.stub();
79
126
  services.logger.error = sinon.stub();
127
+ services.trigger = sinon.stub();
80
128
 
81
129
  services.initialize();
82
130
 
83
- // call the onReady callback
131
+ // call the onLoaded callback
84
132
  services.listenToOnce.getCall(1).args[2]();
85
133
 
86
134
  await waitForAsync();
87
135
 
88
136
  assert.isTrue(services.initFailed);
137
+ assert.isTrue(services.ready);
138
+ sinon.assert.calledWith(services.trigger, 'services:initialized');
89
139
  sinon.assert.calledWith(
90
140
  services.logger.error,
91
141
  `services: failed to init initial services when no credentials available, ${expectedMessage}`
@@ -95,32 +145,132 @@ describe('webex-core', () => {
95
145
 
96
146
  it.each([
97
147
  {error: new Error('failed'), expectedMessage: 'failed'},
98
- {error: undefined, expectedMessage: undefined}
99
- ])('sets initFailed to true when initServiceCatalogs errors', async ({error, expectedMessage}) => {
100
- services.initServiceCatalogs = sinon.stub().callsFake(() => {
101
- return Promise.reject(error);
102
- });
103
- services.webex.credentials = {
104
- supertoken: {
105
- access_token: 'token'
106
- }
148
+ {error: undefined, expectedMessage: undefined},
149
+ ])(
150
+ 'sets initFailed to true when initServiceCatalogs errors but still sets ready to true',
151
+ async ({error, expectedMessage}) => {
152
+ services.initServiceCatalogs = sinon.stub().callsFake(() => {
153
+ return Promise.reject(error);
154
+ });
155
+ services.webex.credentials = {
156
+ supertoken: {
157
+ access_token: 'token',
158
+ },
159
+ };
160
+
161
+ services.listenToOnce = sinon.stub();
162
+ services.logger.error = sinon.stub();
163
+ services.trigger = sinon.stub();
164
+
165
+ services.initialize();
166
+
167
+ // call the onLoaded callback
168
+ services.listenToOnce.getCall(1).args[2]();
169
+
170
+ await waitForAsync();
171
+
172
+ assert.isTrue(services.initFailed);
173
+ assert.isTrue(services.ready);
174
+ sinon.assert.calledWith(services.trigger, 'services:initialized');
175
+ sinon.assert.calledWith(
176
+ services.logger.error,
177
+ `services: failed to init initial services when credentials available, ${expectedMessage}`
178
+ );
107
179
  }
180
+ );
108
181
 
109
- services.listenToOnce = sinon.stub();
110
- services.logger.error = sinon.stub();
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 = {};
111
191
 
112
- services.initialize();
192
+ services.initialize();
113
193
 
114
- // call the onReady callback
115
- services.listenToOnce.getCall(1).args[2]();
194
+ // Get the catalog after initialize creates it
195
+ const testCatalog = services._getCatalog();
196
+ testCatalog.status.postauth.ready = false;
116
197
 
117
- await waitForAsync();
198
+ // call the onLoaded callback (preauth path)
199
+ services.listenToOnce.getCall(1).args[2]();
200
+ await waitForAsync();
118
201
 
119
- assert.isTrue(services.initFailed);
120
- sinon.assert.calledWith(
121
- services.logger.error,
122
- `services: failed to init initial services when credentials available, ${expectedMessage}`
123
- );
202
+ // Now set canAuthorize to true to simulate auth completing
203
+ services.webex.canAuthorize = true;
204
+
205
+ // call the change:canAuthorize callback (should be call 2)
206
+ services.listenToOnce.getCall(2).args[2]();
207
+ await waitForAsync();
208
+
209
+ sinon.assert.calledOnce(services.initServiceCatalogs);
210
+ assert.isTrue(testCatalog.isReady);
211
+ });
212
+
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 = {};
221
+
222
+ services.initialize();
223
+
224
+ // Get the catalog after initialize creates it
225
+ const testCatalog = services._getCatalog();
226
+
227
+ // call the onLoaded callback (preauth path)
228
+ services.listenToOnce.getCall(1).args[2]();
229
+ await waitForAsync();
230
+
231
+ // Set canAuthorize to true and postauth as ready BEFORE calling the callback
232
+ services.webex.canAuthorize = true;
233
+ testCatalog.status.postauth.ready = true;
234
+
235
+ // call the change:canAuthorize callback (should be call 2)
236
+ services.listenToOnce.getCall(2).args[2]();
237
+ await waitForAsync();
238
+
239
+ sinon.assert.notCalled(services.initServiceCatalogs);
240
+ });
241
+
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 = {};
251
+
252
+ services.initialize();
253
+
254
+ // Get the catalog after initialize creates it
255
+ const testCatalog = services._getCatalog();
256
+ testCatalog.status.postauth.ready = false;
257
+
258
+ // call the onLoaded callback (preauth path)
259
+ services.listenToOnce.getCall(1).args[2]();
260
+ await waitForAsync();
261
+
262
+ // Now set canAuthorize to true to simulate auth completing
263
+ services.webex.canAuthorize = true;
264
+
265
+ // call the change:canAuthorize callback (should be call 2)
266
+ services.listenToOnce.getCall(2).args[2]();
267
+ await waitForAsync();
268
+
269
+ sinon.assert.calledWith(
270
+ services.logger.error,
271
+ 'services: failed to init service catalogs after auth, auth failed'
272
+ );
273
+ });
124
274
  });
125
275
  });
126
276
 
@@ -159,7 +309,7 @@ describe('webex-core', () => {
159
309
 
160
310
  services.collectPreauthCatalog = sinon.stub().callsFake(() => {
161
311
  return Promise.resolve();
162
- })
312
+ });
163
313
 
164
314
  services.updateServices = sinon.stub().callsFake(() => {
165
315
  return Promise.reject(error);
@@ -254,7 +404,7 @@ describe('webex-core', () => {
254
404
  discovery: {
255
405
  sqdiscovery: 'https://test.ciscospark.com/v1/region',
256
406
  },
257
- }
407
+ },
258
408
  };
259
409
  });
260
410
 
@@ -274,8 +424,8 @@ describe('webex-core', () => {
274
424
  assert.calledWith(webex.request, {
275
425
  uri: 'https://test.ciscospark.com/v1/region',
276
426
  addAuthHeader: false,
277
- headers: { 'spark-user-agent': null },
278
- timeout: 5000
427
+ headers: {'spark-user-agent': null},
428
+ timeout: 5000,
279
429
  });
280
430
  });
281
431
  });
@@ -332,7 +482,6 @@ describe('webex-core', () => {
332
482
  });
333
483
 
334
484
  describe('#_fetchNewServiceHostmap()', () => {
335
-
336
485
  beforeEach(() => {
337
486
  sinon.spy(webex.internal.newMetrics.callDiagnosticLatencies, 'measureLatency');
338
487
  });
@@ -346,19 +495,20 @@ describe('webex-core', () => {
346
495
 
347
496
  sinon.stub(services, '_formatReceivedHostmap').resolves(mapResponse);
348
497
  sinon.stub(services, 'request').resolves({});
349
-
350
- const mapResult = await services._fetchNewServiceHostmap({from: 'limited'});
351
498
 
352
- assert.deepEqual(mapResult, mapResponse);
499
+ const mapResult = await services._fetchNewServiceHostmap({from: 'limited'});
353
500
 
354
501
  assert.calledOnceWithExactly(services.request, {
355
502
  method: 'GET',
356
503
  service: 'u2c',
357
504
  resource: '/limited/catalog',
358
- qs: {format: 'hostmap'}
359
- }
505
+ qs: {format: 'hostmap'},
506
+ });
507
+ assert.calledOnceWithExactly(
508
+ webex.internal.newMetrics.callDiagnosticLatencies.measureLatency,
509
+ sinon.match.func,
510
+ 'internal.get.u2c.time'
360
511
  );
361
- assert.calledOnceWithExactly(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, sinon.match.func, 'internal.get.u2c.time');
362
512
  });
363
513
 
364
514
  it('checks service request rejects', async () => {
@@ -366,7 +516,7 @@ describe('webex-core', () => {
366
516
 
367
517
  sinon.spy(services, '_formatReceivedHostmap');
368
518
  sinon.stub(services, 'request').rejects(error);
369
-
519
+
370
520
  const promise = services._fetchNewServiceHostmap({from: 'limited'});
371
521
  const rejectedValue = await assert.isRejected(promise);
372
522
 
@@ -378,10 +528,13 @@ describe('webex-core', () => {
378
528
  method: 'GET',
379
529
  service: 'u2c',
380
530
  resource: '/limited/catalog',
381
- qs: {format: 'hostmap'}
382
- }
531
+ qs: {format: 'hostmap'},
532
+ });
533
+ assert.calledOnceWithExactly(
534
+ webex.internal.newMetrics.callDiagnosticLatencies.measureLatency,
535
+ sinon.match.func,
536
+ 'internal.get.u2c.time'
383
537
  );
384
- assert.calledOnceWithExactly(webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, sinon.match.func, 'internal.get.u2c.time');
385
538
  });
386
539
  });
387
540
 
@@ -412,7 +565,6 @@ describe('webex-core', () => {
412
565
  });
413
566
 
414
567
  it('returns the original uri if the hostmap has no hosts for the host', () => {
415
-
416
568
  services._hostCatalog = {
417
569
  'example.com': [],
418
570
  };
@@ -576,8 +728,7 @@ describe('webex-core', () => {
576
728
  id: '0:0:0:different-e-x',
577
729
  },
578
730
  ],
579
- 'example-f.com': [
580
- ],
731
+ 'example-f.com': [],
581
732
  },
582
733
  format: 'hostmap',
583
734
  };
@@ -785,7 +936,7 @@ describe('webex-core', () => {
785
936
  defaultUrl: 'https://example-g.com/api/v1',
786
937
  hosts: [],
787
938
  name: 'example-g',
788
- }
939
+ },
789
940
  ]);
790
941
  });
791
942
 
@@ -839,63 +990,92 @@ describe('webex-core', () => {
839
990
  assert.equal(webex.config.credentials.authorizeUrl, authUrl);
840
991
  });
841
992
  });
842
-
993
+
843
994
  describe('#getMobiusClusters', () => {
844
995
  it('returns unique mobius host entries from hostCatalog', () => {
845
996
  // Arrange: two hostCatalog keys, with duplicate mobius host across keys
846
997
  services._hostCatalog = {
847
- 'mobius-a.webex.com': [
848
- {host: 'mobius-a.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:mobius'},
849
- {host: 'mobius-b.webex.com', ttl: -1, priority: 10, id: 'urn:TEAM:xyz:mobius'},
850
- {host: 'ignore.webex.com', ttl: -1, priority: 1, id: 'urn:TEAM:abc:wdm'}, // not mobius
998
+ 'mobius-us-east-2.prod.infra.webex.com': [
999
+ {
1000
+ host: 'mobius-us-east-2.prod.infra.webex.com',
1001
+ ttl: -1,
1002
+ priority: 5,
1003
+ id: 'urn:TEAM:xyz:mobius',
1004
+ },
1005
+ {
1006
+ host: 'mobius-eu-central-1.prod.infra.webex.com',
1007
+ ttl: -1,
1008
+ priority: 10,
1009
+ id: 'urn:TEAM:xyz:mobius',
1010
+ },
851
1011
  ],
852
- 'dup-entry-key': [
853
- {host: 'mobius-a.webex.com', ttl: -1, priority: 7, id: 'urn:TEAM:xyz:mobius'}, // duplicate host
1012
+
1013
+ 'mobius-eu-central-1.prod.infra.webex.com': [
1014
+ {
1015
+ host: 'mobius-us-east-2.prod.infra.webex.com',
1016
+ ttl: -1,
1017
+ priority: 7,
1018
+ id: 'urn:TEAM:xyz:mobius',
1019
+ }, // duplicate host
1020
+ ],
1021
+ 'wdm-a.webex.com': [
1022
+ {host: 'wdm-a.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:wdm'},
854
1023
  ],
855
1024
  };
856
-
1025
+
857
1026
  // Act
858
1027
  const clusters = services.getMobiusClusters();
859
-
1028
+
860
1029
  // Assert
861
1030
  // deduped; only mobius entries; keeps first seen mobius-a, then mobius-b
862
1031
  assert.deepEqual(
863
1032
  clusters.map(({host, id, ttl, priority}) => ({host, id, ttl, priority})),
864
1033
  [
865
- {host: 'mobius-a.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 5},
866
- {host: 'mobius-b.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 10},
1034
+ {
1035
+ host: 'mobius-us-east-2.prod.infra.webex.com',
1036
+ id: 'urn:TEAM:xyz:mobius',
1037
+ ttl: -1,
1038
+ priority: 5,
1039
+ },
1040
+ {
1041
+ host: 'mobius-eu-central-1.prod.infra.webex.com',
1042
+ id: 'urn:TEAM:xyz:mobius',
1043
+ ttl: -1,
1044
+ priority: 10,
1045
+ },
867
1046
  ]
868
1047
  );
869
1048
  });
870
1049
  });
1050
+
871
1051
  describe('#isValidHost', () => {
872
1052
  beforeEach(() => {
873
1053
  // Setting up a mock host catalog
874
- services._hostCatalog = {
875
- "audit-ci-m.wbx2.com": [
876
- {
877
- "host": "audit-ci-m.wbx2.com",
878
- "ttl": -1,
879
- "priority": 5,
880
- "id": "urn:IDENTITY:PA61:adminAudit"
881
- },
882
- {
883
- "host": "audit-ci-m.wbx2.com",
884
- "ttl": -1,
885
- "priority": 5,
886
- "id": "urn:IDENTITY:PA61:adminAuditV2"
887
- }
888
- ],
889
- "mercury-connection-partition0-r.wbx2.com": [
890
- {
891
- "host": "mercury-connection-partition0-r.wbx2.com",
892
- "ttl": -1,
893
- "priority": 5,
894
- "id": "urn:TEAM:us-west-2_r:mercuryConnectionPartition0"
895
- }
896
- ],
897
- "empty.com": []
898
- };
1054
+ services._hostCatalog = {
1055
+ 'audit-ci-m.wbx2.com': [
1056
+ {
1057
+ host: 'audit-ci-m.wbx2.com',
1058
+ ttl: -1,
1059
+ priority: 5,
1060
+ id: 'urn:IDENTITY:PA61:adminAudit',
1061
+ },
1062
+ {
1063
+ host: 'audit-ci-m.wbx2.com',
1064
+ ttl: -1,
1065
+ priority: 5,
1066
+ id: 'urn:IDENTITY:PA61:adminAuditV2',
1067
+ },
1068
+ ],
1069
+ 'mercury-connection-partition0-r.wbx2.com': [
1070
+ {
1071
+ host: 'mercury-connection-partition0-r.wbx2.com',
1072
+ ttl: -1,
1073
+ priority: 5,
1074
+ id: 'urn:TEAM:us-west-2_r:mercuryConnectionPartition0',
1075
+ },
1076
+ ],
1077
+ 'empty.com': [],
1078
+ };
899
1079
  });
900
1080
  afterAll(() => {
901
1081
  // Clean up the mock host catalog
@@ -919,6 +1099,426 @@ describe('webex-core', () => {
919
1099
  assert.isFalse(services.isValidHost([]));
920
1100
  });
921
1101
  });
1102
+
1103
+ describe('#isIntegrationEnvironment', () => {
1104
+ it('returns true when u2c URL contains "intb"', () => {
1105
+ services.webex.config = {
1106
+ services: {
1107
+ discovery: {
1108
+ u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1',
1109
+ },
1110
+ },
1111
+ };
1112
+ assert.isTrue(services.isIntegrationEnvironment());
1113
+ });
1114
+
1115
+ it('returns false when u2c URL does not contain "intb" (production)', () => {
1116
+ services.webex.config = {
1117
+ services: {
1118
+ discovery: {
1119
+ u2c: 'https://u2c.wbx2.com/u2c/api/v1',
1120
+ },
1121
+ },
1122
+ };
1123
+ assert.isFalse(services.isIntegrationEnvironment());
1124
+ });
1125
+
1126
+ it('returns false when u2c URL is for FedRAMP', () => {
1127
+ services.webex.config = {
1128
+ services: {
1129
+ discovery: {
1130
+ u2c: 'https://u2c.gov.ciscospark.com/u2c/api/v1',
1131
+ },
1132
+ },
1133
+ };
1134
+ assert.isFalse(services.isIntegrationEnvironment());
1135
+ });
1136
+
1137
+ it('returns false when u2c URL is undefined', () => {
1138
+ services.webex.config = {
1139
+ services: {
1140
+ discovery: {
1141
+ u2c: undefined,
1142
+ },
1143
+ },
1144
+ };
1145
+ assert.isFalse(services.isIntegrationEnvironment());
1146
+ });
1147
+
1148
+ it('returns false when config is not available', () => {
1149
+ services.webex.config = undefined;
1150
+ assert.isFalse(services.isIntegrationEnvironment());
1151
+ });
1152
+ });
1153
+
1154
+ describe('U2C catalog cache behavior', () => {
1155
+ let webex;
1156
+ let services;
1157
+ let catalog;
1158
+ let localStorageBackup;
1159
+ let windowBackup;
1160
+
1161
+ const makeLocalStorageShim = () => {
1162
+ const store = new Map();
1163
+ return {
1164
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
1165
+ setItem: (k, v) => store.set(k, v),
1166
+ removeItem: (k) => store.delete(k),
1167
+ _store: store,
1168
+ };
1169
+ };
1170
+
1171
+ beforeEach(() => {
1172
+ // Build a fresh webex instance
1173
+ webex = new MockWebex({
1174
+ children: {services: Services},
1175
+ config: {credentials: {federation: true}},
1176
+ });
1177
+ services = webex.internal.services;
1178
+ catalog = services._getCatalog();
1179
+
1180
+ // enable U2C caching feature flag in tests that rely on localStorage writes/reads
1181
+ services.webex.config = services.webex.config || {};
1182
+ services.webex.config.calling = {...(services.webex.config.calling || {}), cacheU2C: true};
1183
+
1184
+ // stub window.localStorage
1185
+ windowBackup = global.window;
1186
+ if (!global.window) global.window = {};
1187
+ localStorageBackup = global.window.localStorage;
1188
+ global.window.localStorage = makeLocalStorageShim();
1189
+ // Ensure code under test uses our shim via util method
1190
+ sinon.stub(services, '_getLocalStorageSafe').returns(global.window.localStorage);
1191
+
1192
+ // Stub the formatter so we don't need a full hostmap payload in tests
1193
+ sinon
1194
+ .stub(services, '_formatReceivedHostmap')
1195
+ .callsFake(() => [
1196
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1197
+ ]);
1198
+ });
1199
+
1200
+ afterEach(() => {
1201
+ global.window.localStorage = localStorageBackup || undefined;
1202
+ if (!windowBackup) {
1203
+ delete global.window;
1204
+ } else {
1205
+ global.window = windowBackup;
1206
+ }
1207
+ // Restore util stub if present
1208
+ if (services._getLocalStorageSafe && services._getLocalStorageSafe.restore) {
1209
+ services._getLocalStorageSafe.restore();
1210
+ }
1211
+ });
1212
+
1213
+ it('invokes initServiceCatalogs on ready, caches catalog, and stores in localStorage', async () => {
1214
+ // Arrange: authenticated credentials and spies
1215
+ services.webex.credentials = {
1216
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1217
+ canAuthorize: true,
1218
+ supertoken: {access_token: 'token'},
1219
+ };
1220
+ const initSpy = sinon.spy(services, 'initServiceCatalogs');
1221
+ const cacheSpy = sinon.spy(services, '_cacheCatalog');
1222
+ const setItemSpy = sinon.spy(global.window.localStorage, 'setItem');
1223
+ // Make fetch return a hostmap object and allow formatter to reduce it
1224
+ sinon
1225
+ .stub(services, 'request')
1226
+ .resolves({
1227
+ body: {
1228
+ services: [],
1229
+ activeServices: {},
1230
+ timestamp: Date.now().toString(),
1231
+ orgId: 'urn:EXAMPLE:org',
1232
+ format: 'U2CV2',
1233
+ },
1234
+ });
1235
+ // Cause loaded callback to run immediately
1236
+ services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
1237
+ if (event === 'loaded') cb();
1238
+ });
1239
+
1240
+ // Act
1241
+ services.initialize();
1242
+ await waitForAsync();
1243
+
1244
+ // Assert: initServiceCatalogs was called because there was no cache
1245
+ assert.isTrue(initSpy.called, 'expected initServiceCatalogs to be invoked on ready');
1246
+ // _cacheCatalog is called at least once (preauth/postauth flows)
1247
+ assert.isTrue(cacheSpy.called, 'expected _cacheCatalog to be called');
1248
+ assert.isTrue(setItemSpy.called, 'expected localStorage.setItem to be called');
1249
+
1250
+ // Cleanup spies
1251
+ services.request.restore();
1252
+ initSpy.restore();
1253
+ cacheSpy.restore();
1254
+ setItemSpy.restore();
1255
+ });
1256
+
1257
+ it('does not invoke initServiceCatalogs on ready when cache exists and uses cached catalog', async () => {
1258
+ // Arrange: put a valid cache
1259
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1260
+ const cached = {
1261
+ orgId: 'urn:EXAMPLE:org',
1262
+ cachedAt: Date.now(),
1263
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1264
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1265
+ };
1266
+ global.window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(cached));
1267
+
1268
+ // authenticated credentials
1269
+ services.webex.credentials = {
1270
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1271
+ canAuthorize: true,
1272
+ supertoken: {access_token: 'token'},
1273
+ };
1274
+
1275
+ const initSpy = sinon.spy(services, 'initServiceCatalogs');
1276
+ const cacheSpy = sinon.spy(services, '_cacheCatalog');
1277
+ // Cause loaded callback to run immediately
1278
+ services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
1279
+ if (event === 'loaded') cb();
1280
+ });
1281
+
1282
+ // Act
1283
+ services.initialize();
1284
+ await waitForAsync();
1285
+
1286
+ // Assert: ready path found cache and skipped initServiceCatalogs
1287
+ assert.isFalse(
1288
+ initSpy.called,
1289
+ 'expected initServiceCatalogs to be skipped with cache present'
1290
+ );
1291
+ assert.isTrue(
1292
+ services._getCatalog().status.preauth.ready,
1293
+ 'preauth should be ready from cache'
1294
+ );
1295
+ assert.isTrue(
1296
+ services._getCatalog().status.postauth.ready,
1297
+ 'postauth should be ready from cache'
1298
+ );
1299
+ assert.isFalse(cacheSpy.called, 'should not write cache during warm-up-only path');
1300
+
1301
+ // Cleanup
1302
+ initSpy.restore();
1303
+ cacheSpy.restore();
1304
+ });
1305
+
1306
+ it('expires cached catalog after TTL and clears the entry', async () => {
1307
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1308
+ const staleCached = {
1309
+ orgId: 'urn:EXAMPLE:org',
1310
+ cachedAt: Date.now() - (24 * 60 * 60 * 1000 + 1000), // past TTL
1311
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1312
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1313
+ };
1314
+
1315
+ window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(staleCached));
1316
+
1317
+ const warmed = await services._loadCatalogFromCache();
1318
+
1319
+ assert.isFalse(warmed, 'stale cache must not warm');
1320
+ assert.isNull(
1321
+ window.localStorage.getItem(CATALOG_CACHE_KEY_V1),
1322
+ 'expired cache must be cleared'
1323
+ );
1324
+ assert.isFalse(catalog.status.preauth.ready);
1325
+ assert.isFalse(catalog.status.postauth.ready);
1326
+ });
1327
+
1328
+ it('clearCatalogCache() removes the cached entry', async () => {
1329
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1330
+ window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify({cachedAt: Date.now()}));
1331
+
1332
+ await services.clearCatalogCache();
1333
+
1334
+ assert.isNull(window.localStorage.getItem(CATALOG_CACHE_KEY_V1), 'cache should be cleared');
1335
+ });
1336
+
1337
+ it('still fetches when forceRefresh=true even if ready', async () => {
1338
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1339
+ window.localStorage.setItem(
1340
+ CATALOG_CACHE_KEY_V1,
1341
+ JSON.stringify({
1342
+ orgId: 'urn:EXAMPLE:org',
1343
+ cachedAt: Date.now(),
1344
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1345
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1346
+ })
1347
+ );
1348
+
1349
+ // warm from cache
1350
+ const warmed = await services._loadCatalogFromCache();
1351
+ assert.isTrue(warmed);
1352
+ assert.isTrue(catalog.status.preauth.ready);
1353
+ assert.isTrue(catalog.status.postauth.ready);
1354
+
1355
+ const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap');
1356
+
1357
+ // with forceRefresh we should fetch despite ready=true
1358
+ await services.updateServices({
1359
+ from: 'limited',
1360
+ query: {orgId: 'urn:EXAMPLE:org'},
1361
+ forceRefresh: true,
1362
+ });
1363
+ // pass an empty query to avoid spreading undefined in qs construction
1364
+ await services.updateServices({forceRefresh: true});
1365
+
1366
+ assert.isTrue(fetchSpy.called, 'forceRefresh should bypass cache short-circuit');
1367
+ fetchSpy.restore();
1368
+ });
1369
+
1370
+ it('stores selection metadata and env on cache write for preauth', async () => {
1371
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1372
+ // arrange config for env fingerprint
1373
+ services.webex.config = services.webex.config || {};
1374
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1375
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1376
+ services.webex.config.fedramp = false;
1377
+
1378
+ // write cache with meta
1379
+ await services._cacheCatalog(
1380
+ 'preauth',
1381
+ {serviceLinks: {}, hostCatalog: {}},
1382
+ {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}
1383
+ );
1384
+
1385
+ const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1);
1386
+ assert.isString(raw);
1387
+ const parsed = JSON.parse(raw);
1388
+ assert.equal(parsed.orgId, undefined, 'orgId not set without credentials');
1389
+ assert.deepEqual(parsed.env, {
1390
+ fedramp: false,
1391
+ u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1',
1392
+ });
1393
+ assert.isObject(parsed.preauth);
1394
+ assert.deepEqual(parsed.preauth.meta, {
1395
+ selectionType: 'orgId',
1396
+ selectionValue: 'urn:EXAMPLE:org',
1397
+ });
1398
+ });
1399
+
1400
+ it('warms preauth from cache when selection meta matches intended orgId', async () => {
1401
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1402
+ // stub credentials
1403
+ services.webex.credentials = {
1404
+ canAuthorize: true,
1405
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1406
+ };
1407
+ // set current env to match cached env
1408
+ services.webex.config = services.webex.config || {};
1409
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1410
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1411
+ services.webex.config.fedramp = false;
1412
+ // cache with matching orgId selection
1413
+ window.localStorage.setItem(
1414
+ CATALOG_CACHE_KEY_V1,
1415
+ JSON.stringify({
1416
+ cachedAt: Date.now(),
1417
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1418
+ preauth: {
1419
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1420
+ meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'},
1421
+ },
1422
+ })
1423
+ );
1424
+ // formatter returns at least one entry to mark ready
1425
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1426
+ sinon
1427
+ .stub(services, '_formatReceivedHostmap')
1428
+ .callsFake(() => [
1429
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1430
+ ]);
1431
+
1432
+ const warmed = await services._loadCatalogFromCache();
1433
+ assert.isTrue(warmed);
1434
+ assert.isTrue(catalog.status.preauth.ready, 'preauth should be warmed on match');
1435
+ });
1436
+
1437
+ it('does not warm preauth when selection meta is proximity mode', async () => {
1438
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1439
+ // cache with proximity mode selection
1440
+ window.localStorage.setItem(
1441
+ CATALOG_CACHE_KEY_V1,
1442
+ JSON.stringify({
1443
+ cachedAt: Date.now(),
1444
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1445
+ preauth: {
1446
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1447
+ meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'},
1448
+ },
1449
+ })
1450
+ );
1451
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1452
+ sinon
1453
+ .stub(services, '_formatReceivedHostmap')
1454
+ .callsFake(() => [
1455
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1456
+ ]);
1457
+
1458
+ const warmed = await services._loadCatalogFromCache();
1459
+ // function returns true if overall cache path succeeded; we only verify group readiness
1460
+ assert.isFalse(catalog.status.preauth.ready, 'preauth should not warm for proximity mode');
1461
+ });
1462
+
1463
+ it('does not warm preauth when selection meta mismatches intended selection', async () => {
1464
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1465
+ // authorized with org X
1466
+ services.webex.credentials = {
1467
+ canAuthorize: true,
1468
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1469
+ };
1470
+ // cache points to a different org
1471
+ window.localStorage.setItem(
1472
+ CATALOG_CACHE_KEY_V1,
1473
+ JSON.stringify({
1474
+ cachedAt: Date.now(),
1475
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1476
+ preauth: {
1477
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1478
+ meta: {selectionType: 'orgId', selectionValue: 'urn:DIFF:org'},
1479
+ },
1480
+ })
1481
+ );
1482
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1483
+ sinon
1484
+ .stub(services, '_formatReceivedHostmap')
1485
+ .callsFake(() => [
1486
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1487
+ ]);
1488
+
1489
+ await services._loadCatalogFromCache();
1490
+ assert.isFalse(
1491
+ catalog.status.preauth.ready,
1492
+ 'preauth should not warm on selection mismatch'
1493
+ );
1494
+ });
1495
+
1496
+ it('skips warming when environment fingerprint mismatches', async () => {
1497
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1498
+ // cached env differs from current env (different U2C URL)
1499
+ window.localStorage.setItem(
1500
+ CATALOG_CACHE_KEY_V1,
1501
+ JSON.stringify({
1502
+ cachedAt: Date.now(),
1503
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.other.com/u2c/api/v1'},
1504
+ preauth: {
1505
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1506
+ meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'},
1507
+ },
1508
+ })
1509
+ );
1510
+ // current env
1511
+ services.webex.config = services.webex.config || {};
1512
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1513
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1514
+ services.webex.config.fedramp = false;
1515
+
1516
+ const warmed = await services._loadCatalogFromCache();
1517
+ assert.isFalse(warmed, 'env mismatch should skip warm and return false');
1518
+ assert.isFalse(catalog.status.preauth.ready);
1519
+ assert.isFalse(catalog.status.postauth.ready);
1520
+ });
1521
+ });
922
1522
  });
923
1523
  });
924
1524
  /* eslint-enable no-underscore-dangle */