@webex/webex-core 3.10.0 → 3.11.0

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 (118) hide show
  1. package/dist/config.js +14 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/credentials-config.js.map +1 -1
  4. package/dist/index.js +1 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/interceptors/auth.js +6 -8
  7. package/dist/interceptors/auth.js.map +1 -1
  8. package/dist/interceptors/default-options.js +6 -8
  9. package/dist/interceptors/default-options.js.map +1 -1
  10. package/dist/interceptors/embargo.js +6 -8
  11. package/dist/interceptors/embargo.js.map +1 -1
  12. package/dist/interceptors/network-timing.js +6 -8
  13. package/dist/interceptors/network-timing.js.map +1 -1
  14. package/dist/interceptors/payload-transformer.js +6 -8
  15. package/dist/interceptors/payload-transformer.js.map +1 -1
  16. package/dist/interceptors/proxy.js +7 -10
  17. package/dist/interceptors/proxy.js.map +1 -1
  18. package/dist/interceptors/rate-limit.js +7 -10
  19. package/dist/interceptors/rate-limit.js.map +1 -1
  20. package/dist/interceptors/redirect.js +9 -8
  21. package/dist/interceptors/redirect.js.map +1 -1
  22. package/dist/interceptors/request-event.js +6 -8
  23. package/dist/interceptors/request-event.js.map +1 -1
  24. package/dist/interceptors/request-logger.js +6 -8
  25. package/dist/interceptors/request-logger.js.map +1 -1
  26. package/dist/interceptors/request-timing.js +6 -8
  27. package/dist/interceptors/request-timing.js.map +1 -1
  28. package/dist/interceptors/response-logger.js +6 -8
  29. package/dist/interceptors/response-logger.js.map +1 -1
  30. package/dist/interceptors/user-agent.js +8 -11
  31. package/dist/interceptors/user-agent.js.map +1 -1
  32. package/dist/interceptors/webex-tracking-id.js +6 -8
  33. package/dist/interceptors/webex-tracking-id.js.map +1 -1
  34. package/dist/interceptors/webex-user-agent.js +7 -10
  35. package/dist/interceptors/webex-user-agent.js.map +1 -1
  36. package/dist/lib/batcher.js +1 -1
  37. package/dist/lib/batcher.js.map +1 -1
  38. package/dist/lib/constants.js.map +1 -1
  39. package/dist/lib/credentials/credentials.js +4 -6
  40. package/dist/lib/credentials/credentials.js.map +1 -1
  41. package/dist/lib/credentials/grant-errors.js +18 -26
  42. package/dist/lib/credentials/grant-errors.js.map +1 -1
  43. package/dist/lib/credentials/index.js.map +1 -1
  44. package/dist/lib/credentials/scope.js.map +1 -1
  45. package/dist/lib/credentials/token-collection.js.map +1 -1
  46. package/dist/lib/credentials/token.js +5 -5
  47. package/dist/lib/credentials/token.js.map +1 -1
  48. package/dist/lib/interceptors/hostmap.js +6 -8
  49. package/dist/lib/interceptors/hostmap.js.map +1 -1
  50. package/dist/lib/interceptors/server-error.js +6 -8
  51. package/dist/lib/interceptors/server-error.js.map +1 -1
  52. package/dist/lib/interceptors/service.js +6 -8
  53. package/dist/lib/interceptors/service.js.map +1 -1
  54. package/dist/lib/metrics.js.map +1 -1
  55. package/dist/lib/page.js +5 -6
  56. package/dist/lib/page.js.map +1 -1
  57. package/dist/lib/services/index.js.map +1 -1
  58. package/dist/lib/services/service-catalog.js +3 -3
  59. package/dist/lib/services/service-catalog.js.map +1 -1
  60. package/dist/lib/services/service-fed-ramp.js.map +1 -1
  61. package/dist/lib/services/service-host.js +1 -2
  62. package/dist/lib/services/service-host.js.map +1 -1
  63. package/dist/lib/services/service-registry.js +1 -2
  64. package/dist/lib/services/service-registry.js.map +1 -1
  65. package/dist/lib/services/service-state.js +1 -2
  66. package/dist/lib/services/service-state.js.map +1 -1
  67. package/dist/lib/services/service-url.js +11 -1
  68. package/dist/lib/services/service-url.js.map +1 -1
  69. package/dist/lib/services/services.js +485 -127
  70. package/dist/lib/services/services.js.map +1 -1
  71. package/dist/lib/services-v2/index.js.map +1 -1
  72. package/dist/lib/services-v2/metrics.js.map +1 -1
  73. package/dist/lib/services-v2/service-catalog.js +7 -7
  74. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  75. package/dist/lib/services-v2/service-detail.js.map +1 -1
  76. package/dist/lib/services-v2/service-fed-ramp.js.map +1 -1
  77. package/dist/lib/services-v2/services-v2.js +379 -51
  78. package/dist/lib/services-v2/services-v2.js.map +1 -1
  79. package/dist/lib/services-v2/types.js.map +1 -1
  80. package/dist/lib/stateless-webex-plugin.js +3 -4
  81. package/dist/lib/stateless-webex-plugin.js.map +1 -1
  82. package/dist/lib/storage/decorators.js.map +1 -1
  83. package/dist/lib/storage/errors.js +7 -9
  84. package/dist/lib/storage/errors.js.map +1 -1
  85. package/dist/lib/storage/index.js.map +1 -1
  86. package/dist/lib/storage/make-webex-plugin-store.js +14 -5
  87. package/dist/lib/storage/make-webex-plugin-store.js.map +1 -1
  88. package/dist/lib/storage/make-webex-store.js +13 -5
  89. package/dist/lib/storage/make-webex-store.js.map +1 -1
  90. package/dist/lib/storage/memory-store-adapter.js.map +1 -1
  91. package/dist/lib/webex-core-plugin-mixin.js.map +1 -1
  92. package/dist/lib/webex-http-error.js +8 -11
  93. package/dist/lib/webex-http-error.js.map +1 -1
  94. package/dist/lib/webex-internal-core-plugin-mixin.js.map +1 -1
  95. package/dist/lib/webex-plugin.js.map +1 -1
  96. package/dist/plugins/logger.js +1 -1
  97. package/dist/plugins/logger.js.map +1 -1
  98. package/dist/webex-core.js +11 -11
  99. package/dist/webex-core.js.map +1 -1
  100. package/dist/webex-internal-core.js.map +1 -1
  101. package/package.json +13 -13
  102. package/src/config.js +15 -0
  103. package/src/interceptors/redirect.js +4 -0
  104. package/src/lib/services/service-url.js +9 -1
  105. package/src/lib/services/services.js +315 -7
  106. package/src/lib/services-v2/index.ts +0 -1
  107. package/src/lib/services-v2/service-catalog.ts +4 -4
  108. package/src/lib/services-v2/services-v2.ts +307 -7
  109. package/src/lib/services-v2/types.ts +13 -0
  110. package/test/fixtures/host-catalog-v2.ts +1 -1
  111. package/test/integration/spec/services/service-catalog.js +10 -4
  112. package/test/integration/spec/services/services.js +65 -9
  113. package/test/integration/spec/services-v2/service-catalog.js +2 -2
  114. package/test/integration/spec/services-v2/services-v2.js +56 -6
  115. package/test/unit/spec/interceptors/redirect.js +98 -0
  116. package/test/unit/spec/services/service-url.js +110 -0
  117. package/test/unit/spec/services/services.js +411 -2
  118. package/test/unit/spec/services-v2/services-v2.ts +316 -0
@@ -168,6 +168,104 @@ describe('webex-core', () => {
168
168
  uri: 'http://newlocus.example.com',
169
169
  });
170
170
  });
171
+
172
+ it('removes authorization header when redirecting preJoin request to webex-appapi-service', () => {
173
+ const response = {
174
+ statusCode: 404,
175
+ headers: {},
176
+ body: {
177
+ code: 404100,
178
+ data: {
179
+ siteFullUrl: 'newlocus.example.com'
180
+ },
181
+ },
182
+ };
183
+
184
+ const options = {
185
+ $redirectCount: 0,
186
+ uri: 'https://test.webex.com/meet/v1/preJoin',
187
+ resource: 'preJoin',
188
+ service: 'webex-appapi-service',
189
+ headers: {
190
+ authorization: 'Bearer token123',
191
+ },
192
+ };
193
+
194
+ interceptor.onResponse(options, response);
195
+ sinon.assert.calledWith(webex.request, sinon.match({
196
+ $redirectCount: 1,
197
+ uri: 'https://newlocus.example.com/meet/v1/preJoin',
198
+ resource: 'preJoin',
199
+ service: 'webex-appapi-service',
200
+ headers: {
201
+ authorization: false,
202
+ },
203
+ }));
204
+ });
205
+
206
+ it('keeps authorization header for non-preJoin requests on appapi redirect', () => {
207
+ const response = {
208
+ statusCode: 404,
209
+ headers: {},
210
+ body: {
211
+ code: 404100,
212
+ data: {
213
+ siteFullUrl: 'newlocus.example.com'
214
+ },
215
+ },
216
+ };
217
+
218
+ const options = {
219
+ $redirectCount: 0,
220
+ uri: 'https://test.webex.com/meet/v1/join',
221
+ resource: 'join',
222
+ service: 'webex-appapi-service',
223
+ headers: {
224
+ authorization: 'Bearer token123',
225
+ },
226
+ };
227
+
228
+ interceptor.onResponse(options, response);
229
+ sinon.assert.calledWith(webex.request, sinon.match({
230
+ $redirectCount: 1,
231
+ uri: 'https://newlocus.example.com/meet/v1/join',
232
+ headers: {
233
+ authorization: 'Bearer token123',
234
+ },
235
+ }));
236
+ });
237
+
238
+ it('keeps authorization header for preJoin requests to non-webex-appapi-service', () => {
239
+ const response = {
240
+ statusCode: 404,
241
+ headers: {},
242
+ body: {
243
+ code: 404100,
244
+ data: {
245
+ siteFullUrl: 'newlocus.example.com'
246
+ },
247
+ },
248
+ };
249
+
250
+ const options = {
251
+ $redirectCount: 0,
252
+ uri: 'https://test.webex.com/meet/v1/preJoin',
253
+ resource: 'preJoin',
254
+ service: 'other-service',
255
+ headers: {
256
+ authorization: 'Bearer token123',
257
+ },
258
+ };
259
+
260
+ interceptor.onResponse(options, response);
261
+ sinon.assert.calledWith(webex.request, sinon.match({
262
+ $redirectCount: 1,
263
+ uri: 'https://newlocus.example.com/meet/v1/preJoin',
264
+ headers: {
265
+ authorization: 'Bearer token123',
266
+ },
267
+ }));
268
+ });
171
269
  });
172
270
  });
173
271
  });
@@ -151,6 +151,116 @@ describe('webex-core', () => {
151
151
 
152
152
  assert.isTrue(homeClusterUrls.every((host) => !host.failed));
153
153
  });
154
+
155
+ describe('when hosts have negative priorities', () => {
156
+ it('should return defaultUrl when all hosts have negative priorities', () => {
157
+ const negativeServiceUrl = new ServiceUrl({
158
+ defaultUrl: 'https://default.example.com/api/v1',
159
+ hosts: [
160
+ {
161
+ host: 'example-host-neg1.com',
162
+ priority: -1,
163
+ ttl: -1,
164
+ id: '1',
165
+ homeCluster: true,
166
+ },
167
+ {
168
+ host: 'example-host-neg2.com',
169
+ priority: -1,
170
+ ttl: -1,
171
+ id: '2',
172
+ homeCluster: true,
173
+ },
174
+ ],
175
+ name: 'negative-priority-test',
176
+ });
177
+
178
+ assert.equal(
179
+ negativeServiceUrl._getPriorityHostUrl(),
180
+ 'https://default.example.com/api/v1'
181
+ );
182
+ });
183
+
184
+ it('should return defaultUrl when all hosts have zero priority', () => {
185
+ const zeroServiceUrl = new ServiceUrl({
186
+ defaultUrl: 'https://default.example.com/api/v1',
187
+ hosts: [
188
+ {
189
+ host: 'example-host-zero.com',
190
+ priority: 0,
191
+ ttl: -1,
192
+ id: '1',
193
+ homeCluster: true,
194
+ },
195
+ ],
196
+ name: 'zero-priority-test',
197
+ });
198
+
199
+ assert.equal(zeroServiceUrl._getPriorityHostUrl(), 'https://default.example.com/api/v1');
200
+ });
201
+
202
+ it('should ignore hosts with negative priorities and return valid host', () => {
203
+ const mixedServiceUrl = new ServiceUrl({
204
+ defaultUrl: 'https://default.example.com/api/v1',
205
+ hosts: [
206
+ {
207
+ host: 'example-host-neg.com',
208
+ priority: -1,
209
+ ttl: -1,
210
+ id: '1',
211
+ homeCluster: true,
212
+ },
213
+ {
214
+ host: 'example-host-valid.com',
215
+ priority: 5,
216
+ ttl: -1,
217
+ id: '2',
218
+ homeCluster: true,
219
+ },
220
+ ],
221
+ name: 'mixed-priority-test',
222
+ });
223
+
224
+ const result = mixedServiceUrl._getPriorityHostUrl();
225
+
226
+ assert.include(result, 'example-host-valid.com');
227
+ assert.notInclude(result, 'example-host-neg.com');
228
+ });
229
+
230
+ it('should select lowest positive priority host when mixed with negative priorities', () => {
231
+ const mixedServiceUrl = new ServiceUrl({
232
+ defaultUrl: 'https://default.example.com/api/v1',
233
+ hosts: [
234
+ {
235
+ host: 'example-host-neg.com',
236
+ priority: -1,
237
+ ttl: -1,
238
+ id: '1',
239
+ homeCluster: true,
240
+ },
241
+ {
242
+ host: 'example-host-p5.com',
243
+ priority: 5,
244
+ ttl: -1,
245
+ id: '2',
246
+ homeCluster: true,
247
+ },
248
+ {
249
+ host: 'example-host-p2.com',
250
+ priority: 2,
251
+ ttl: -1,
252
+ id: '3',
253
+ homeCluster: true,
254
+ },
255
+ ],
256
+ name: 'mixed-priority-test',
257
+ });
258
+
259
+ const result = mixedServiceUrl._getPriorityHostUrl();
260
+
261
+ assert.include(result, 'example-host-p2.com');
262
+ });
263
+ });
154
264
  });
155
265
 
156
266
  describe('#failHost()', () => {
@@ -349,8 +349,6 @@ describe('webex-core', () => {
349
349
 
350
350
  const mapResult = await services._fetchNewServiceHostmap({from: 'limited'});
351
351
 
352
- assert.deepEqual(mapResult, mapResponse);
353
-
354
352
  assert.calledOnceWithExactly(services.request, {
355
353
  method: 'GET',
356
354
  service: 'u2c',
@@ -839,6 +837,417 @@ describe('webex-core', () => {
839
837
  assert.equal(webex.config.credentials.authorizeUrl, authUrl);
840
838
  });
841
839
  });
840
+
841
+ describe('#getMobiusClusters', () => {
842
+ it('returns unique mobius host entries from hostCatalog', () => {
843
+ // Arrange: two hostCatalog keys, with duplicate mobius host across keys
844
+ services._hostCatalog = {
845
+ 'mobius-us-east-2.prod.infra.webex.com': [
846
+ {host: 'mobius-us-east-2.prod.infra.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:mobius'},
847
+ {host: 'mobius-eu-central-1.prod.infra.webex.com', ttl: -1, priority: 10, id: 'urn:TEAM:xyz:mobius'},
848
+ ],
849
+
850
+ 'mobius-eu-central-1.prod.infra.webex.com': [
851
+ {host: 'mobius-us-east-2.prod.infra.webex.com', ttl: -1, priority: 7, id: 'urn:TEAM:xyz:mobius'}, // duplicate host
852
+ ],
853
+ 'wdm-a.webex.com' : [
854
+ {host: 'wdm-a.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:wdm'},
855
+ ]
856
+ };
857
+
858
+ // Act
859
+ const clusters = services.getMobiusClusters();
860
+
861
+ // Assert
862
+ // deduped; only mobius entries; keeps first seen mobius-a, then mobius-b
863
+ assert.deepEqual(
864
+ clusters.map(({host, id, ttl, priority}) => ({host, id, ttl, priority})),
865
+ [
866
+ {host: 'mobius-us-east-2.prod.infra.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 5},
867
+ {host: 'mobius-eu-central-1.prod.infra.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 10},
868
+ ]
869
+ );
870
+ });
871
+ });
872
+
873
+ describe('#isValidHost', () => {
874
+ beforeEach(() => {
875
+ // Setting up a mock host catalog
876
+ services._hostCatalog = {
877
+ "audit-ci-m.wbx2.com": [
878
+ {
879
+ "host": "audit-ci-m.wbx2.com",
880
+ "ttl": -1,
881
+ "priority": 5,
882
+ "id": "urn:IDENTITY:PA61:adminAudit"
883
+ },
884
+ {
885
+ "host": "audit-ci-m.wbx2.com",
886
+ "ttl": -1,
887
+ "priority": 5,
888
+ "id": "urn:IDENTITY:PA61:adminAuditV2"
889
+ }
890
+ ],
891
+ "mercury-connection-partition0-r.wbx2.com": [
892
+ {
893
+ "host": "mercury-connection-partition0-r.wbx2.com",
894
+ "ttl": -1,
895
+ "priority": 5,
896
+ "id": "urn:TEAM:us-west-2_r:mercuryConnectionPartition0"
897
+ }
898
+ ],
899
+ "empty.com": []
900
+ };
901
+ });
902
+ afterAll(() => {
903
+ // Clean up the mock host catalog
904
+ services._hostCatalog = {};
905
+ });
906
+ it('returns true if the host is in the host catalog', () => {
907
+ assert.isTrue(services.isValidHost('mercury-connection-partition0-r.wbx2.com'));
908
+ });
909
+
910
+ it('returns false if the host is not in the host catalog or has an empty entry list', () => {
911
+ assert.isFalse(services.isValidHost('test.com'));
912
+ assert.isFalse(services.isValidHost(''));
913
+ assert.isFalse(services.isValidHost(null));
914
+ assert.isFalse(services.isValidHost(undefined));
915
+ assert.isFalse(services.isValidHost('empty.com'));
916
+ });
917
+
918
+ it('returns false for non-string inputs', () => {
919
+ assert.isFalse(services.isValidHost(123));
920
+ assert.isFalse(services.isValidHost({}));
921
+ assert.isFalse(services.isValidHost([]));
922
+ });
923
+ });
924
+
925
+ describe('U2C catalog cache behavior', () => {
926
+ let webex;
927
+ let services;
928
+ let catalog;
929
+ let localStorageBackup;
930
+ let windowBackup;
931
+
932
+ const makeLocalStorageShim = () => {
933
+ const store = new Map();
934
+ return {
935
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
936
+ setItem: (k, v) => store.set(k, v),
937
+ removeItem: (k) => store.delete(k),
938
+ _store: store,
939
+ };
940
+ };
941
+
942
+ beforeEach(() => {
943
+ // Build a fresh webex instance
944
+ webex = new MockWebex({children: {services: Services}, config: {credentials: {federation: true}}});
945
+ services = webex.internal.services;
946
+ catalog = services._getCatalog();
947
+
948
+ // enable U2C caching feature flag in tests that rely on localStorage writes/reads
949
+ services.webex.config = services.webex.config || {};
950
+ services.webex.config.calling = {...(services.webex.config.calling || {}), cacheU2C: true};
951
+
952
+ // stub window.localStorage
953
+ windowBackup = global.window;
954
+ if (!global.window) global.window = {};
955
+ localStorageBackup = global.window.localStorage;
956
+ global.window.localStorage = makeLocalStorageShim();
957
+ // Ensure code under test uses our shim via util method
958
+ sinon.stub(services, '_getLocalStorageSafe').returns(global.window.localStorage);
959
+
960
+ // Stub the formatter so we don't need a full hostmap payload in tests
961
+ sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [
962
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
963
+ ]);
964
+ });
965
+
966
+ afterEach(() => {
967
+ global.window.localStorage = localStorageBackup || undefined;
968
+ if (!windowBackup) {
969
+ delete global.window;
970
+ } else {
971
+ global.window = windowBackup;
972
+ }
973
+ // Restore util stub if present
974
+ if (services._getLocalStorageSafe && services._getLocalStorageSafe.restore) {
975
+ services._getLocalStorageSafe.restore();
976
+ }
977
+ });
978
+
979
+ it('invokes initServiceCatalogs on ready, caches catalog, and stores in localStorage', async () => {
980
+ // Arrange: authenticated credentials and spies
981
+ services.webex.credentials = {
982
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
983
+ canAuthorize: true,
984
+ supertoken: {access_token: 'token'},
985
+ };
986
+ const initSpy = sinon.spy(services, 'initServiceCatalogs');
987
+ const cacheSpy = sinon.spy(services, '_cacheCatalog');
988
+ const setItemSpy = sinon.spy(global.window.localStorage, 'setItem');
989
+ // Make fetch return a hostmap object and allow formatter to reduce it
990
+ sinon.stub(services, 'request').resolves({body: {services: [], activeServices: {}, timestamp: Date.now().toString(), orgId: 'urn:EXAMPLE:org', format: 'U2CV2'}});
991
+ // Cause ready callback to run immediately
992
+ services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
993
+ if (event === 'ready') cb();
994
+ });
995
+
996
+ // Act
997
+ services.initialize();
998
+ await waitForAsync();
999
+
1000
+ // Assert: initServiceCatalogs was called because there was no cache
1001
+ assert.isTrue(initSpy.called, 'expected initServiceCatalogs to be invoked on ready');
1002
+ // _cacheCatalog is called at least once (preauth/postauth flows)
1003
+ assert.isTrue(cacheSpy.called, 'expected _cacheCatalog to be called');
1004
+ assert.isTrue(setItemSpy.called, 'expected localStorage.setItem to be called');
1005
+
1006
+ // Cleanup spies
1007
+ services.request.restore();
1008
+ initSpy.restore();
1009
+ cacheSpy.restore();
1010
+ setItemSpy.restore();
1011
+ });
1012
+
1013
+ it('does not invoke initServiceCatalogs on ready when cache exists and uses cached catalog', async () => {
1014
+ // Arrange: put a valid cache
1015
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1016
+ const cached = {
1017
+ orgId: 'urn:EXAMPLE:org',
1018
+ cachedAt: Date.now(),
1019
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1020
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1021
+ };
1022
+ global.window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(cached));
1023
+
1024
+ // authenticated credentials
1025
+ services.webex.credentials = {
1026
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1027
+ canAuthorize: true,
1028
+ supertoken: {access_token: 'token'},
1029
+ };
1030
+
1031
+ const initSpy = sinon.spy(services, 'initServiceCatalogs');
1032
+ const cacheSpy = sinon.spy(services, '_cacheCatalog');
1033
+ // Cause ready callback to run immediately
1034
+ services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => {
1035
+ if (event === 'ready') cb();
1036
+ });
1037
+
1038
+ // Act
1039
+ services.initialize();
1040
+ await waitForAsync();
1041
+
1042
+ // Assert: ready path found cache and skipped initServiceCatalogs
1043
+ assert.isFalse(initSpy.called, 'expected initServiceCatalogs to be skipped with cache present');
1044
+ assert.isTrue(services._getCatalog().status.preauth.ready, 'preauth should be ready from cache');
1045
+ assert.isTrue(services._getCatalog().status.postauth.ready, 'postauth should be ready from cache');
1046
+ assert.isFalse(cacheSpy.called, 'should not write cache during warm-up-only path');
1047
+
1048
+ // Cleanup
1049
+ initSpy.restore();
1050
+ cacheSpy.restore();
1051
+ });
1052
+
1053
+ it('expires cached catalog after TTL and clears the entry', async () => {
1054
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1055
+ const staleCached = {
1056
+ orgId: 'urn:EXAMPLE:org',
1057
+ cachedAt: Date.now() - (24 * 60 * 60 * 1000 + 1000), // past TTL
1058
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1059
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1060
+ };
1061
+
1062
+ window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(staleCached));
1063
+
1064
+ const warmed = await services._loadCatalogFromCache();
1065
+
1066
+ assert.isFalse(warmed, 'stale cache must not warm');
1067
+ assert.isNull(window.localStorage.getItem(CATALOG_CACHE_KEY_V1), 'expired cache must be cleared');
1068
+ assert.isFalse(catalog.status.preauth.ready);
1069
+ assert.isFalse(catalog.status.postauth.ready);
1070
+ });
1071
+
1072
+ it('clearCatalogCache() removes the cached entry', async () => {
1073
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1074
+ window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify({cachedAt: Date.now()}));
1075
+
1076
+ await services.clearCatalogCache();
1077
+
1078
+ assert.isNull(window.localStorage.getItem(CATALOG_CACHE_KEY_V1), 'cache should be cleared');
1079
+ });
1080
+
1081
+ it('still fetches when forceRefresh=true even if ready', async () => {
1082
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1083
+ window.localStorage.setItem(
1084
+ CATALOG_CACHE_KEY_V1,
1085
+ JSON.stringify({
1086
+ orgId: 'urn:EXAMPLE:org',
1087
+ cachedAt: Date.now(),
1088
+ preauth: {serviceLinks: {}, hostCatalog: {}},
1089
+ postauth: {serviceLinks: {}, hostCatalog: {}},
1090
+ })
1091
+ );
1092
+
1093
+ // warm from cache
1094
+ const warmed = await services._loadCatalogFromCache();
1095
+ assert.isTrue(warmed);
1096
+ assert.isTrue(catalog.status.preauth.ready);
1097
+ assert.isTrue(catalog.status.postauth.ready);
1098
+
1099
+ const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap');
1100
+
1101
+ // with forceRefresh we should fetch despite ready=true
1102
+ await services.updateServices({from: 'limited', query: {orgId: 'urn:EXAMPLE:org'}, forceRefresh: true});
1103
+ // pass an empty query to avoid spreading undefined in qs construction
1104
+ await services.updateServices({forceRefresh: true});
1105
+
1106
+ assert.isTrue(fetchSpy.called, 'forceRefresh should bypass cache short-circuit');
1107
+ fetchSpy.restore();
1108
+ });
1109
+
1110
+ it('stores selection metadata and env on cache write for preauth', async () => {
1111
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1112
+ // arrange config for env fingerprint
1113
+ services.webex.config = services.webex.config || {};
1114
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1115
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1116
+ services.webex.config.fedramp = false;
1117
+
1118
+ // write cache with meta
1119
+ await services._cacheCatalog(
1120
+ 'preauth',
1121
+ {serviceLinks: {}, hostCatalog: {}},
1122
+ {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}
1123
+ );
1124
+
1125
+ const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1);
1126
+ assert.isString(raw);
1127
+ const parsed = JSON.parse(raw);
1128
+ assert.equal(parsed.orgId, undefined, 'orgId not set without credentials');
1129
+ assert.deepEqual(parsed.env, {
1130
+ fedramp: false,
1131
+ u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1',
1132
+ });
1133
+ assert.isObject(parsed.preauth);
1134
+ assert.deepEqual(parsed.preauth.meta, {
1135
+ selectionType: 'orgId',
1136
+ selectionValue: 'urn:EXAMPLE:org',
1137
+ });
1138
+ });
1139
+
1140
+ it('warms preauth from cache when selection meta matches intended orgId', async () => {
1141
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1142
+ // stub credentials
1143
+ services.webex.credentials = {
1144
+ canAuthorize: true,
1145
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1146
+ };
1147
+ // set current env to match cached env
1148
+ services.webex.config = services.webex.config || {};
1149
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1150
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1151
+ services.webex.config.fedramp = false;
1152
+ // cache with matching orgId selection
1153
+ window.localStorage.setItem(
1154
+ CATALOG_CACHE_KEY_V1,
1155
+ JSON.stringify({
1156
+ cachedAt: Date.now(),
1157
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1158
+ preauth: {
1159
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1160
+ meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'},
1161
+ },
1162
+ })
1163
+ );
1164
+ // formatter returns at least one entry to mark ready
1165
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1166
+ sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [
1167
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1168
+ ]);
1169
+
1170
+ const warmed = await services._loadCatalogFromCache();
1171
+ assert.isTrue(warmed);
1172
+ assert.isTrue(catalog.status.preauth.ready, 'preauth should be warmed on match');
1173
+ });
1174
+
1175
+ it('does not warm preauth when selection meta is proximity mode', async () => {
1176
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1177
+ // cache with proximity mode selection
1178
+ window.localStorage.setItem(
1179
+ CATALOG_CACHE_KEY_V1,
1180
+ JSON.stringify({
1181
+ cachedAt: Date.now(),
1182
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1183
+ preauth: {
1184
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1185
+ meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'},
1186
+ },
1187
+ })
1188
+ );
1189
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1190
+ sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [
1191
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1192
+ ]);
1193
+
1194
+ const warmed = await services._loadCatalogFromCache();
1195
+ // function returns true if overall cache path succeeded; we only verify group readiness
1196
+ assert.isFalse(catalog.status.preauth.ready, 'preauth should not warm for proximity mode');
1197
+ });
1198
+
1199
+ it('does not warm preauth when selection meta mismatches intended selection', async () => {
1200
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1201
+ // authorized with org X
1202
+ services.webex.credentials = {
1203
+ canAuthorize: true,
1204
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1205
+ };
1206
+ // cache points to a different org
1207
+ window.localStorage.setItem(
1208
+ CATALOG_CACHE_KEY_V1,
1209
+ JSON.stringify({
1210
+ cachedAt: Date.now(),
1211
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1212
+ preauth: {
1213
+ hostMap: {serviceLinks: {}, hostCatalog: {}},
1214
+ meta: {selectionType: 'orgId', selectionValue: 'urn:DIFF:org'},
1215
+ },
1216
+ })
1217
+ );
1218
+ services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore();
1219
+ sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [
1220
+ {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []},
1221
+ ]);
1222
+
1223
+ await services._loadCatalogFromCache();
1224
+ assert.isFalse(catalog.status.preauth.ready, 'preauth should not warm on selection mismatch');
1225
+ });
1226
+
1227
+ it('skips warming when environment fingerprint mismatches', async () => {
1228
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
1229
+ // cached env differs from current env (different U2C URL)
1230
+ window.localStorage.setItem(
1231
+ CATALOG_CACHE_KEY_V1,
1232
+ JSON.stringify({
1233
+ cachedAt: Date.now(),
1234
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.other.com/u2c/api/v1'},
1235
+ preauth: {hostMap: {serviceLinks: {}, hostCatalog: {}}, meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}},
1236
+ })
1237
+ );
1238
+ // current env
1239
+ services.webex.config = services.webex.config || {};
1240
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1241
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1242
+ services.webex.config.fedramp = false;
1243
+
1244
+ const warmed = await services._loadCatalogFromCache();
1245
+ assert.isFalse(warmed, 'env mismatch should skip warm and return false');
1246
+ assert.isFalse(catalog.status.preauth.ready);
1247
+ assert.isFalse(catalog.status.postauth.ready);
1248
+ });
1249
+ });
1250
+
842
1251
  });
843
1252
  });
844
1253
  /* eslint-enable no-underscore-dangle */