From 944c94ce55a2c20d054ca91f2f30e3bd183a0b92 Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 12 Nov 2025 00:38:44 +0530 Subject: [PATCH 1/5] feat(calling): catalog cache changes --- .../internal-plugin-device/src/device.js | 2 + .../src/lib/services/service-catalog.js | 6 + .../webex-core/src/lib/services/services.js | 142 +++++++++++++++++- .../test/unit/spec/services/services.js | 125 +++++++++++++++ 4 files changed, 271 insertions(+), 4 deletions(-) diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js index 671019c0dc4..b6fe565dc81 100644 --- a/packages/@webex/internal-plugin-device/src/device.js +++ b/packages/@webex/internal-plugin-device/src/device.js @@ -667,6 +667,8 @@ const Device = WebexPlugin.extend({ const {services} = this.webex.internal; // Wait for the postauth catalog to populate. + console.log('pkesari_canRegister invoking waitForCatalog for postauth'); + return services.waitForCatalog('postauth', this.config.canRegisterWaitDuration).then(() => // Validate that the service exists after waiting for the catalog. services.get('wdm') diff --git a/packages/@webex/webex-core/src/lib/services/service-catalog.js b/packages/@webex/webex-core/src/lib/services/service-catalog.js index db8da22a072..1736cf75b00 100644 --- a/packages/@webex/webex-core/src/lib/services/service-catalog.js +++ b/packages/@webex/webex-core/src/lib/services/service-catalog.js @@ -412,6 +412,12 @@ const ServiceCatalog = AmpState.extend({ } }); + console.log( + 'pkesari_status before updating the ready status for service group', + serviceGroup, + this.status[serviceGroup] + ); + console.log('pkesari_updating the ready status to true for service group: ', serviceGroup); this.status[serviceGroup].ready = true; this.trigger(serviceGroup); diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index 61ebc1b7b79..ab04b70335e 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -20,6 +20,8 @@ export const DEFAULT_CLUSTER_SERVICE = 'identityLookup'; const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAULT_CLUSTER_SERVICE; const DEFAULT_CLUSTER_IDENTIFIER = process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`; +const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; +const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours /* eslint-disable no-underscore-dangle */ /** @@ -191,7 +193,7 @@ const Services = WebexPlugin.extend({ * @param {string} [param.token] - used for signin catalog * @returns {Promise} */ - updateServices({from, query, token, forceRefresh} = {}) { + async updateServices({from, query, token, forceRefresh} = {}) { const catalog = this._getCatalog(); let formattedQuery; let serviceGroup; @@ -245,7 +247,9 @@ const Services = WebexPlugin.extend({ forceRefresh, }) .then((serviceHostMap) => { - catalog.updateServiceUrls(serviceGroup, serviceHostMap); + const formattedServiceHostMap = this._formatReceivedHostmap(serviceHostMap); + this._cacheCatalog(serviceGroup, serviceHostMap); + catalog.updateServiceUrls(serviceGroup, formattedServiceHostMap); this.updateCredentialsConfig(); catalog.status[serviceGroup].collecting = false; }) @@ -958,7 +962,129 @@ const Services = WebexPlugin.extend({ return this.webex.internal.newMetrics.callDiagnosticLatencies .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time') - .then(({body}) => this._formatReceivedHostmap(body)); + .then(({body}) => body); + }, + + /** + * Cache the catalog in the bounded storage. + * @param {string} serviceGroup - preauth, signin, postauth + * @param {object} hostMap - The hostmap to cache + * @returns {Promise} + * + */ + async _cacheCatalog(serviceGroup, hostMap) { + try { + // Persist to localStorage to survive browser refresh + let current = {}; + try { + const raw = + typeof window !== 'undefined' && window.localStorage + ? window.localStorage.getItem(CATALOG_CACHE_KEY_V1) + : null; + current = raw ? JSON.parse(raw) : {}; + } catch (e) { + current = {}; + } + let orgId; + try { + const {credentials} = this.webex; + orgId = credentials.getOrgId(); + } catch (e) { + orgId = current.orgId; + } + + const updated = { + ...current, + orgId: orgId || current.orgId, + [serviceGroup]: hostMap, + cachedAt: Date.now(), + }; + + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(updated)); + } + console.log('pkesari_cacheCatalog cached catalog: ', updated); + } catch (error) { + // ignore storage errors + } + }, + + /** + * Load the catalog from cache and hydrate the in-memory ServiceCatalog. + * @returns {Promise} true if cache was loaded, false otherwise + */ + async _loadCatalogFromCache() { + console.log('pkesari_loadCatalogFromCache loading catalog from cache'); + try { + if (typeof window === 'undefined' || !window.localStorage) { + return false; + } + const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1); + const cached = raw ? JSON.parse(raw) : undefined; + console.log('pkesari_loadCatalogFromCache cached catalog found: ', cached); + if (!cached) { + return false; + } + // TTL enforcement: clear if older than 24 hours + const cachedAt = Number(cached.cachedAt) || 0; + if (!cachedAt || Date.now() - cachedAt > CATALOG_TTL_MS) { + try { + this.clearCatalogCache(); + } catch (e) { + // ignore + } + + return false; + } + + // If authorized, ensure cached org matches + try { + if (this.webex.credentials?.canAuthorize) { + const {credentials} = this.webex; + const currentOrgId = credentials.getOrgId(); + if (cached.orgId && cached.orgId !== currentOrgId) { + return false; + } + } + } catch (e) { + // ignore orgId check errors + } + + const catalog = this._getCatalog(); + + // Apply any cached groups + const groups = ['preauth', 'signin', 'postauth']; + groups.forEach((g) => { + if (cached[g]) { + const formatted = this._formatReceivedHostmap(cached[g]); + console.log('pkesari_loadCatalogFromCache updating service urls for group: ', g); + catalog.updateServiceUrls(g, formatted); + } + }); + + // Align credentials against warmed catalog + this.updateCredentialsConfig(); + + return true; + } catch (e) { + return false; + } + }, + + /** + * Clear the catalog cache from the bounded storage. + * @returns {Promise} + */ + clearCatalogCache() { + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(CATALOG_CACHE_KEY_V1); + } + } catch (e) { + // ignore + } + + return Promise.resolve(); }, /** @@ -1038,6 +1164,7 @@ const Services = WebexPlugin.extend({ // Validate if the token is authorized. if (credentials.canAuthorize) { // Attempt to collect the postauth catalog. + return this.updateServices().catch(() => { this.initFailed = true; this.logger.warn('services: cannot retrieve postauth catalog'); @@ -1073,7 +1200,14 @@ const Services = WebexPlugin.extend({ // wait for webex instance to be ready before attempting // to update the service catalogs - this.listenToOnce(this.webex, 'ready', () => { + this.listenToOnce(this.webex, 'ready', async () => { + const cachedCatalog = await this._loadCatalogFromCache(); + if (cachedCatalog) { + catalog.isReady = true; + + return; // skip initServiceCatalogs() on reload when cache exists + } + const {supertoken} = this.webex.credentials; // Validate if the supertoken exists. if (supertoken && supertoken.access_token) { diff --git a/packages/@webex/webex-core/test/unit/spec/services/services.js b/packages/@webex/webex-core/test/unit/spec/services/services.js index 03cb672708e..27682c1b7b6 100644 --- a/packages/@webex/webex-core/test/unit/spec/services/services.js +++ b/packages/@webex/webex-core/test/unit/spec/services/services.js @@ -839,6 +839,131 @@ describe('webex-core', () => { assert.equal(webex.config.credentials.authorizeUrl, authUrl); }); }); + + describe('U2C catalog cache behavior', () => { + let webex; + let services; + let catalog; + let localStorageBackup; + + // simple in-memory localStorage shim + const makeLocalStorageShim = () => { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + _store: store, + }; + }; + + beforeEach(() => { + // build a fresh webex instance + // use the standard helper you use elsewhere in this file to construct WebexCore if available + webex = new WebexCore({config: {credentials: {federation: true}}}); + services = webex.internal.services; + catalog = services._getCatalog(); + + // stub window.localStorage + localStorageBackup = global.window.localStorage; + global.window.localStorage = makeLocalStorageShim(); + + // Stub the formatter so we don't need a full hostmap payload in tests + sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [ + {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []}, + ]); + }); + + afterEach(() => { + services._formatReceivedHostmap.restore(); + global.window.localStorage = localStorageBackup; + }); + + it('warms catalog from localStorage on load and short-circuits updateServices()', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + const cached = { + orgId: 'urn:EXAMPLE:org', + cachedAt: Date.now(), // fresh + preauth: {serviceLinks: {}, hostCatalog: {}}, + postauth: {serviceLinks: {}, hostCatalog: {}}, + }; + + window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(cached)); + + // warm from cache + const warmed = await services._loadCatalogFromCache(); + assert.isTrue(warmed, 'expected cache warm to succeed'); + + // both groups become ready via updateServiceUrls + assert.isTrue(catalog.status.preauth.ready); + assert.isTrue(catalog.status.postauth.ready); + + // ensure updateServices short-circuits when ready && !forceRefresh + const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap'); + + await services.updateServices({from: 'limited'}); + await services.updateServices(); // postauth path + + assert.isFalse(fetchSpy.called, 'should not fetch when catalog group is ready'); + fetchSpy.restore(); + }); + + it('expires cached catalog after TTL and clears the entry', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + const staleCached = { + orgId: 'urn:EXAMPLE:org', + cachedAt: Date.now() - (24 * 60 * 60 * 1000 + 1000), // past TTL + preauth: {serviceLinks: {}, hostCatalog: {}}, + postauth: {serviceLinks: {}, hostCatalog: {}}, + }; + + window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(staleCached)); + + const warmed = await services._loadCatalogFromCache(); + + assert.isFalse(warmed, 'stale cache must not warm'); + assert.isNull(window.localStorage.getItem(CATALOG_CACHE_KEY_V1), 'expired cache must be cleared'); + assert.isFalse(catalog.status.preauth.ready); + assert.isFalse(catalog.status.postauth.ready); + }); + + it('clearCatalogCache() removes the cached entry', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify({cachedAt: Date.now()})); + + await services.clearCatalogCache(); + + assert.isNull(window.localStorage.getItem(CATALOG_CACHE_KEY_V1), 'cache should be cleared'); + }); + + it('still fetches when forceRefresh=true even if ready', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + window.localStorage.setItem( + CATALOG_CACHE_KEY_V1, + JSON.stringify({ + orgId: 'urn:EXAMPLE:org', + cachedAt: Date.now(), + preauth: {serviceLinks: {}, hostCatalog: {}}, + postauth: {serviceLinks: {}, hostCatalog: {}}, + }) + ); + + // warm from cache + const warmed = await services._loadCatalogFromCache(); + assert.isTrue(warmed); + assert.isTrue(catalog.status.preauth.ready); + assert.isTrue(catalog.status.postauth.ready); + + const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap'); + + // with forceRefresh we should fetch despite ready=true + await services.updateServices({from: 'limited', forceRefresh: true}); + await services.updateServices({forceRefresh: true}); + + assert.isTrue(fetchSpy.called, 'forceRefresh should bypass cache short-circuit'); + fetchSpy.restore(); + }); + }); }); }); /* eslint-enable no-underscore-dangle */ From b7fd012a330ba8391655cd0975309657b947eacf Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 12 Nov 2025 23:39:14 +0530 Subject: [PATCH 2/5] feat(calling): ut fixes --- .../webex-core/src/lib/services/services.js | 4 - .../test/unit/spec/services/services.js | 108 +++++++++++++----- 2 files changed, 79 insertions(+), 33 deletions(-) diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index ab04b70335e..3479e2834e7 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -1003,7 +1003,6 @@ const Services = WebexPlugin.extend({ if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(updated)); } - console.log('pkesari_cacheCatalog cached catalog: ', updated); } catch (error) { // ignore storage errors } @@ -1014,14 +1013,12 @@ const Services = WebexPlugin.extend({ * @returns {Promise} true if cache was loaded, false otherwise */ async _loadCatalogFromCache() { - console.log('pkesari_loadCatalogFromCache loading catalog from cache'); try { if (typeof window === 'undefined' || !window.localStorage) { return false; } const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1); const cached = raw ? JSON.parse(raw) : undefined; - console.log('pkesari_loadCatalogFromCache cached catalog found: ', cached); if (!cached) { return false; } @@ -1057,7 +1054,6 @@ const Services = WebexPlugin.extend({ groups.forEach((g) => { if (cached[g]) { const formatted = this._formatReceivedHostmap(cached[g]); - console.log('pkesari_loadCatalogFromCache updating service urls for group: ', g); catalog.updateServiceUrls(g, formatted); } }); diff --git a/packages/@webex/webex-core/test/unit/spec/services/services.js b/packages/@webex/webex-core/test/unit/spec/services/services.js index 27682c1b7b6..cc3562729a9 100644 --- a/packages/@webex/webex-core/test/unit/spec/services/services.js +++ b/packages/@webex/webex-core/test/unit/spec/services/services.js @@ -845,8 +845,8 @@ describe('webex-core', () => { let services; let catalog; let localStorageBackup; + let windowBackup; - // simple in-memory localStorage shim const makeLocalStorageShim = () => { const store = new Map(); return { @@ -858,13 +858,14 @@ describe('webex-core', () => { }; beforeEach(() => { - // build a fresh webex instance - // use the standard helper you use elsewhere in this file to construct WebexCore if available - webex = new WebexCore({config: {credentials: {federation: true}}}); + // Build a fresh webex instance + webex = new MockWebex({children: {services: Services}, config: {credentials: {federation: true}}}); services = webex.internal.services; catalog = services._getCatalog(); // stub window.localStorage + windowBackup = global.window; + if (!global.window) global.window = {}; localStorageBackup = global.window.localStorage; global.window.localStorage = makeLocalStorageShim(); @@ -875,39 +876,88 @@ describe('webex-core', () => { }); afterEach(() => { - services._formatReceivedHostmap.restore(); - global.window.localStorage = localStorageBackup; + global.window.localStorage = localStorageBackup || undefined; + if (!windowBackup) { + delete global.window; + } else { + global.window = windowBackup; + } }); - it('warms catalog from localStorage on load and short-circuits updateServices()', async () => { + it('invokes initServiceCatalogs on ready, caches catalog, and stores in localStorage', async () => { + // Arrange: authenticated credentials and spies + services.webex.credentials = { + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + canAuthorize: true, + supertoken: {access_token: 'token'}, + }; + const initSpy = sinon.spy(services, 'initServiceCatalogs'); + const cacheSpy = sinon.spy(services, '_cacheCatalog'); + const setItemSpy = sinon.spy(global.window.localStorage, 'setItem'); + // Make fetch return a hostmap object and allow formatter to reduce it + sinon.stub(services, 'request').resolves({body: {services: [], activeServices: {}, timestamp: Date.now().toString(), orgId: 'urn:EXAMPLE:org', format: 'U2CV2'}}); + // Cause ready callback to run immediately + services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => { + if (event === 'ready') cb(); + }); + + // Act + services.initialize(); + await waitForAsync(); + + // Assert: initServiceCatalogs was called because there was no cache + assert.isTrue(initSpy.called, 'expected initServiceCatalogs to be invoked on ready'); + // _cacheCatalog is called at least once (preauth/postauth flows) + assert.isTrue(cacheSpy.called, 'expected _cacheCatalog to be called'); + assert.isTrue(setItemSpy.called, 'expected localStorage.setItem to be called'); + + // Cleanup spies + services.request.restore(); + initSpy.restore(); + cacheSpy.restore(); + setItemSpy.restore(); + }); + + it('does not invoke initServiceCatalogs on ready when cache exists and uses cached catalog', async () => { + // Arrange: put a valid cache const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; const cached = { orgId: 'urn:EXAMPLE:org', - cachedAt: Date.now(), // fresh + cachedAt: Date.now(), preauth: {serviceLinks: {}, hostCatalog: {}}, postauth: {serviceLinks: {}, hostCatalog: {}}, }; - - window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(cached)); - - // warm from cache - const warmed = await services._loadCatalogFromCache(); - assert.isTrue(warmed, 'expected cache warm to succeed'); - - // both groups become ready via updateServiceUrls - assert.isTrue(catalog.status.preauth.ready); - assert.isTrue(catalog.status.postauth.ready); - - // ensure updateServices short-circuits when ready && !forceRefresh - const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap'); - - await services.updateServices({from: 'limited'}); - await services.updateServices(); // postauth path - - assert.isFalse(fetchSpy.called, 'should not fetch when catalog group is ready'); - fetchSpy.restore(); + global.window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(cached)); + + // authenticated credentials + services.webex.credentials = { + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + canAuthorize: true, + supertoken: {access_token: 'token'}, + }; + + const initSpy = sinon.spy(services, 'initServiceCatalogs'); + const cacheSpy = sinon.spy(services, '_cacheCatalog'); + // Cause ready callback to run immediately + services.listenToOnce = sinon.stub().callsFake((ctx, event, cb) => { + if (event === 'ready') cb(); + }); + + // Act + services.initialize(); + await waitForAsync(); + + // Assert: ready path found cache and skipped initServiceCatalogs + assert.isFalse(initSpy.called, 'expected initServiceCatalogs to be skipped with cache present'); + assert.isTrue(services._getCatalog().status.preauth.ready, 'preauth should be ready from cache'); + assert.isTrue(services._getCatalog().status.postauth.ready, 'postauth should be ready from cache'); + assert.isFalse(cacheSpy.called, 'should not write cache during warm-up-only path'); + + // Cleanup + initSpy.restore(); + cacheSpy.restore(); }); - + it('expires cached catalog after TTL and clears the entry', async () => { const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; const staleCached = { @@ -957,7 +1007,7 @@ describe('webex-core', () => { const fetchSpy = sinon.spy(services, '_fetchNewServiceHostmap'); // with forceRefresh we should fetch despite ready=true - await services.updateServices({from: 'limited', forceRefresh: true}); + await services.updateServices({from: 'limited', query:{orgId:'urn:EXAMPLE:org'}, forceRefresh: true}); await services.updateServices({forceRefresh: true}); assert.isTrue(fetchSpy.called, 'forceRefresh should bypass cache short-circuit'); From 06aa9d1704b64f7ef58c259ea0b7673c4d48fa10 Mon Sep 17 00:00:00 2001 From: Priya Date: Mon, 1 Dec 2025 00:24:35 +0530 Subject: [PATCH 3/5] fix(services): added more conditions for fetching U2C cache --- packages/@webex/webex-core/src/config.js | 10 + .../src/lib/services-v2/services-v2.ts | 258 +++++++++++++++++- .../webex-core/src/lib/services/services.js | 157 ++++++++++- 3 files changed, 418 insertions(+), 7 deletions(-) diff --git a/packages/@webex/webex-core/src/config.js b/packages/@webex/webex-core/src/config.js index cc90300a268..e7bae09c91a 100644 --- a/packages/@webex/webex-core/src/config.js +++ b/packages/@webex/webex-core/src/config.js @@ -75,6 +75,16 @@ export default { metrics: { type: ['behavioral', 'operational'], }, + /** + * Calling-specific configuration. + */ + calling: { + /** + * Controls whether U2C service catalogs should be cached and warmed from cache. + * When false, the services layer will skip reading and writing the U2C cache. + */ + cacheU2C: false, + }, payloadTransformer: { predicates: [], transforms: [], diff --git a/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts b/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts index e43c353422d..9a92a2e8d25 100644 --- a/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts +++ b/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts @@ -28,6 +28,9 @@ const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAUL const DEFAULT_CLUSTER_IDENTIFIER = process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`; +const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap'; +const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + /* eslint-disable no-underscore-dangle */ /** * @class @@ -234,6 +237,22 @@ const Services = WebexPlugin.extend({ serviceHostMap?.services, serviceHostMap?.timestamp ); + // Build selection metadata for caching discrimination (preauth/signin) + let selectionMeta: {selectionType: string; selectionValue: string} | undefined; + if (serviceGroup === 'preauth' || serviceGroup === 'signin') { + try { + const key = formattedQuery && Object.keys(formattedQuery || {})[0]; + if (key) { + selectionMeta = { + selectionType: key, + selectionValue: (formattedQuery as any)[key], + }; + } + } catch { + // ignore + } + } + (this as any)._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta); this.updateCredentialsConfig(); catalog.status[serviceGroup].collecting = false; }) @@ -915,6 +934,236 @@ const Services = WebexPlugin.extend({ return url.replace(data.defaultUrl, data.priorityUrl); }, + /** + * @private + * Cache the catalog in the bounded storage. + * @param {ServiceGroup} serviceGroup - preauth, signin, postauth + * @param {ServiceHostmap} hostMap - The hostmap to cache + * @param {object} [meta] - Optional selection metadata for cache discrimination + * @returns {Promise} + */ + async _cacheCatalog( + serviceGroup: ServiceGroup, + hostMap: ServiceHostmap, + meta?: {selectionType: string; selectionValue: string} + ): Promise { + try { + // Respect calling.cacheU2C toggle; if disabled, skip writing cache + try { + if ( + (this as any)?.webex?.config?.calling && + (this as any).webex.config.calling.cacheU2C === false + ) { + return; + } + } catch { + // ignore + } + + let current: any = {}; + try { + const raw = + typeof window !== 'undefined' && (window as any).localStorage + ? (window as any).localStorage.getItem(CATALOG_CACHE_KEY_V2) + : null; + current = raw ? JSON.parse(raw) : {}; + } catch { + current = {}; + } + + let orgId: string | undefined; + try { + const {credentials} = (this as any).webex; + orgId = credentials.getOrgId(); + } catch { + orgId = current.orgId; + } + + // Capture environment fingerprint to invalidate cache across env changes + let env: {fedramp?: boolean; u2cDiscoveryUrl?: string} | undefined; + try { + const fedramp = !!(this as any)?.webex?.config?.fedramp; + const u2cDiscoveryUrl = (this as any)?.webex?.config?.services?.discovery?.u2c; + env = {fedramp, u2cDiscoveryUrl}; + } catch { + env = current.env; + } + + const updated = { + ...current, + orgId: orgId || current.orgId, + env: env || current.env, + // When selection meta is provided, store as an object; otherwise keep legacy shape + [serviceGroup]: meta ? {hostMap, meta} : hostMap, + cachedAt: Date.now(), + }; + + if (typeof window !== 'undefined' && (window as any).localStorage) { + (window as any).localStorage.setItem(CATALOG_CACHE_KEY_V2, JSON.stringify(updated)); + } + } catch { + // ignore cache errors + } + }, + + /** + * @private + * Load the catalog from cache and hydrate the in-memory ServiceCatalog. + * @returns {Promise} true if cache was loaded, false otherwise + */ + async _loadCatalogFromCache(): Promise { + try { + // Respect calling.cacheU2C toggle; if disabled, skip using cache + try { + if ( + (this as any)?.webex?.config?.calling && + (this as any).webex.config.calling.cacheU2C === false + ) { + return false; + } + } catch { + // ignore + } + + if (typeof window === 'undefined' || !(window as any).localStorage) { + return false; + } + const raw = (window as any).localStorage.getItem(CATALOG_CACHE_KEY_V2); + const cached = raw ? JSON.parse(raw) : undefined; + if (!cached) { + return false; + } + + // TTL enforcement + const cachedAt = Number(cached.cachedAt) || 0; + if (!cachedAt || Date.now() - cachedAt > CATALOG_TTL_MS) { + try { + (this as any).clearCatalogCache(); + } catch { + // ignore + } + + return false; + } + + // If authorized, ensure cached org matches + try { + if ((this as any).webex.credentials?.canAuthorize) { + const {credentials} = (this as any).webex; + const currentOrgId = credentials.getOrgId(); + if (cached.orgId && cached.orgId !== currentOrgId) { + return false; + } + } + } catch { + // ignore orgId check errors + } + + // Ensure cached environment matches current environment + try { + const fedramp = !!(this as any)?.webex?.config?.fedramp; + const u2cDiscoveryUrl = (this as any)?.webex?.config?.services?.discovery?.u2c; + const currentEnv = {fedramp, u2cDiscoveryUrl}; + if (cached.env) { + const sameEnv = + cached.env.fedramp === currentEnv.fedramp && + cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl; + if (!sameEnv) { + return false; + } + } + } catch { + // ignore env check errors + } + + const catalog = this._getCatalog(); + const groups: Array = ['preauth', 'signin', 'postauth']; + + // Helper: compute intended preauth selection based on current context + const getIntendedPreauthSelection = () => { + try { + if ((this as any).webex.credentials?.canAuthorize) { + const orgId = (this as any).webex.credentials.getOrgId(); + if (orgId) { + return {selectionType: 'orgId', selectionValue: orgId}; + } + } + } catch { + // ignore + } + const emailConfig = (this as any).webex.config && (this as any).webex.config.email; + try { + if (typeof emailConfig === 'string' && emailConfig.trim()) { + return { + selectionType: 'emailhash', + selectionValue: sha256(emailConfig.toLowerCase()).toString(), + }; + } + } catch { + // ignore invalid email config, fall through to proximity mode + } + // fall back to proximity mode when no orgId or email available + + return {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}; + }; + + groups.forEach((g) => { + const cachedGroup = cached[g]; + if (!cachedGroup) { + return; + } + + // Support legacy (hostMap) and new ({hostMap, meta}) shapes + const hostMap: ServiceHostmap = + cachedGroup && cachedGroup.hostMap ? cachedGroup.hostMap : cachedGroup; + const meta: {selectionType: string; selectionValue: string} | undefined = + cachedGroup && cachedGroup.meta ? cachedGroup.meta : undefined; + + if (g === 'preauth' && meta) { + // For proximity-based selection, always fetch fresh to respect IP/region changes + if (meta.selectionType === 'mode') { + return; + } + + const intended = getIntendedPreauthSelection(); + const matches = + intended && + intended.selectionType === meta.selectionType && + intended.selectionValue === meta.selectionValue; + if (!matches) { + return; + } + } + + if (hostMap) { + catalog.updateServiceGroups(g, hostMap?.services, hostMap?.timestamp); + } + }); + + this.updateCredentialsConfig(); + + return true; + } catch { + return false; + } + }, + + /** + * Clear the catalog cache from the bounded storage (v2). + * @returns {Promise} + */ + clearCatalogCache(): Promise { + try { + if (typeof window !== 'undefined' && (window as any).localStorage) { + (window as any).localStorage.removeItem(CATALOG_CACHE_KEY_V2); + } + } catch { + // ignore + } + + return Promise.resolve(); + }, + /** * @private * Simplified method wrapper for sending a request to get @@ -1074,7 +1323,14 @@ const Services = WebexPlugin.extend({ // wait for webex instance to be ready before attempting // to update the service catalogs - this.listenToOnce(this.webex, 'ready', () => { + this.listenToOnce(this.webex, 'ready', async () => { + const warmed = await (this as any)._loadCatalogFromCache(); + if (warmed) { + catalog.isReady = true; + + return; + } + const {supertoken} = this.webex.credentials; // Validate if the supertoken exists. if (supertoken && supertoken.access_token) { diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index f2842b1b695..8784aef61db 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -285,7 +285,22 @@ const Services = WebexPlugin.extend({ }) .then((serviceHostMap) => { const formattedServiceHostMap = this._formatReceivedHostmap(serviceHostMap); - this._cacheCatalog(serviceGroup, serviceHostMap); + // Build selection metadata for caching discrimination + let selectionMeta; + if (serviceGroup === 'preauth' || serviceGroup === 'signin') { + try { + const key = formattedQuery && Object.keys(formattedQuery || {})[0]; + if (key) { + selectionMeta = { + selectionType: key, + selectionValue: formattedQuery[key], + }; + } + } catch (e) { + // ignore + } + } + this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta); catalog.updateServiceUrls(serviceGroup, formattedServiceHostMap); this.updateCredentialsConfig(); catalog.status[serviceGroup].collecting = false; @@ -1006,11 +1021,26 @@ const Services = WebexPlugin.extend({ * Cache the catalog in the bounded storage. * @param {string} serviceGroup - preauth, signin, postauth * @param {object} hostMap - The hostmap to cache + * @param {object} [meta] - Optional selection metadata used to validate cache reuse * @returns {Promise} * */ - async _cacheCatalog(serviceGroup, hostMap) { + async _cacheCatalog(serviceGroup, hostMap, meta) { try { + // Respect calling.cacheU2C toggle; if disabled, skip writing cache + try { + if (this?.webex?.config?.calling && this.webex.config.calling.cacheU2C === false) { + if (this.logger && this.logger.info) { + this.logger.info( + `services: skipping cache write for ${serviceGroup} due to config.calling.cacheU2C=false` + ); + } + + return; + } + } catch (e) { + // ignore + } // Persist to localStorage to survive browser refresh let current = {}; try { @@ -1030,10 +1060,22 @@ const Services = WebexPlugin.extend({ orgId = current.orgId; } + // Capture environment fingerprint to invalidate cache across env changes + let env; + try { + const fedramp = !!this?.webex?.config?.fedramp; + const u2cDiscoveryUrl = this?.webex?.config?.services?.discovery?.u2c; + env = {fedramp, u2cDiscoveryUrl}; + } catch (e) { + env = current.env; + } + const updated = { ...current, orgId: orgId || current.orgId, - [serviceGroup]: hostMap, + env: env || current.env, + // When selection meta is provided, store as an object; otherwise keep legacy shape + [serviceGroup]: meta ? {hostMap, meta} : hostMap, cachedAt: Date.now(), }; @@ -1051,6 +1093,20 @@ const Services = WebexPlugin.extend({ */ async _loadCatalogFromCache() { try { + // Respect calling.cacheU2C toggle; if disabled, skip using cache + try { + if (this?.webex?.config?.calling && this.webex.config.calling.cacheU2C === false) { + if (this.logger && this.logger.info) { + this.logger.info( + 'services: skipping cache warm-up due to config.calling.cacheU2C=false' + ); + } + + return false; + } + } catch (e) { + // ignore + } if (typeof window === 'undefined' || !window.localStorage) { return false; } @@ -1084,13 +1140,102 @@ const Services = WebexPlugin.extend({ // ignore orgId check errors } + // Ensure cached environment matches current environment + try { + const fedramp = !!this?.webex?.config?.fedramp; + const u2cDiscoveryUrl = this?.webex?.config?.services?.discovery?.u2c; + const currentEnv = {fedramp, u2cDiscoveryUrl}; + if (cached.env) { + const sameEnv = + cached.env.fedramp === currentEnv.fedramp && + cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl; + if (!sameEnv) { + if (this.logger && this.logger.info) { + this.logger.info('services: skipping cache warm due to environment mismatch'); + } + + return false; + } + } + } catch (e) { + // ignore env check errors + } + const catalog = this._getCatalog(); - // Apply any cached groups + // Helper: compute intended preauth selection based on current context + const getIntendedPreauthSelection = () => { + try { + if (this.webex.credentials?.canAuthorize) { + const orgId = this.webex.credentials.getOrgId(); + if (orgId) { + return { + selectionType: 'orgId', + selectionValue: orgId, + }; + } + } + } catch (e) { + // ignore + } + const emailConfig = this.webex.config && this.webex.config.email; + try { + if (typeof emailConfig === 'string' && emailConfig.trim()) { + return { + selectionType: 'emailhash', + selectionValue: sha256(emailConfig.toLowerCase()).toString(), + }; + } + } catch (e) { + // ignore invalid email config, fall through to proximity mode + } + + // fall back to proximity mode when no orgId or email available + return { + selectionType: 'mode', + selectionValue: 'DEFAULT_BY_PROXIMITY', + }; + }; + + // Apply any cached groups (with preauth selection validation if available) const groups = ['preauth', 'signin', 'postauth']; groups.forEach((g) => { - if (cached[g]) { - const formatted = this._formatReceivedHostmap(cached[g]); + const cachedGroup = cached[g]; + if (!cachedGroup) { + return; + } + + // Support legacy (hostMap) and new ({hostMap, meta}) shapes + const hostMap = cachedGroup && cachedGroup.hostMap ? cachedGroup.hostMap : cachedGroup; + const meta = cachedGroup && cachedGroup.meta ? cachedGroup.meta : undefined; + + if (g === 'preauth' && meta) { + // For proximity-based selection, always fetch fresh to respect IP/region changes + if (meta.selectionType === 'mode') { + if (this.logger && this.logger.info) { + this.logger.info('services: skipping preauth cache warm for proximity mode'); + } + + return; + } + + const intended = getIntendedPreauthSelection(); + const matches = + intended && + intended.selectionType === meta.selectionType && + intended.selectionValue === meta.selectionValue; + + if (!matches) { + if (this.logger && this.logger.info) { + this.logger.info('services: skipping preauth cache warm due to selection mismatch'); + } + + return; + } + } + + if (hostMap) { + const formatted = this._formatReceivedHostmap(hostMap); catalog.updateServiceUrls(g, formatted); } }); From beb8df4b4e878dab39dda8587caf42d3b1c2e7ac Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 10 Dec 2025 14:17:15 +0530 Subject: [PATCH 4/5] fix(services): added more uts and code cleanup --- docs/samples/calling/app.js | 4 + .../webex-core/src/lib/services/services.js | 67 ++++----- .../test/unit/spec/services/services.js | 135 ++++++++++++++++++ 3 files changed, 165 insertions(+), 41 deletions(-) diff --git a/docs/samples/calling/app.js b/docs/samples/calling/app.js index a111e96dc4c..271a1409333 100644 --- a/docs/samples/calling/app.js +++ b/docs/samples/calling/app.js @@ -223,6 +223,10 @@ async function initCalling(e) { logger: { level: 'debug', // set the desired log level }, + calling: { + // Enable U2C catalog caching for calling sample app + cacheU2C: true, + }, meetings: { reconnection: { enabled: true, diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index 8784aef61db..9859fef56c2 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -1026,23 +1026,20 @@ const Services = WebexPlugin.extend({ * */ async _cacheCatalog(serviceGroup, hostMap, meta) { + let current = {}; + let env; + let orgId; try { // Respect calling.cacheU2C toggle; if disabled, skip writing cache - try { - if (this?.webex?.config?.calling && this.webex.config.calling.cacheU2C === false) { - if (this.logger && this.logger.info) { - this.logger.info( - `services: skipping cache write for ${serviceGroup} due to config.calling.cacheU2C=false` - ); - } + if (this.webex.config?.calling && this.webex.config.calling.cacheU2C === false) { + this.logger.info( + `services: skipping cache write for ${serviceGroup} due to config.calling.cacheU2C=false` + ); - return; - } - } catch (e) { - // ignore + return; } + // Persist to localStorage to survive browser refresh - let current = {}; try { const raw = typeof window !== 'undefined' && window.localStorage @@ -1052,7 +1049,7 @@ const Services = WebexPlugin.extend({ } catch (e) { current = {}; } - let orgId; + try { const {credentials} = this.webex; orgId = credentials.getOrgId(); @@ -1061,10 +1058,9 @@ const Services = WebexPlugin.extend({ } // Capture environment fingerprint to invalidate cache across env changes - let env; try { - const fedramp = !!this?.webex?.config?.fedramp; - const u2cDiscoveryUrl = this?.webex?.config?.services?.discovery?.u2c; + const fedramp = !!this.webex.config?.fedramp; + const u2cDiscoveryUrl = this.webex.config?.services?.discovery?.u2c; env = {fedramp, u2cDiscoveryUrl}; } catch (e) { env = current.env; @@ -1092,21 +1088,15 @@ const Services = WebexPlugin.extend({ * @returns {Promise} true if cache was loaded, false otherwise */ async _loadCatalogFromCache() { + let currentOrgId; try { // Respect calling.cacheU2C toggle; if disabled, skip using cache - try { - if (this?.webex?.config?.calling && this.webex.config.calling.cacheU2C === false) { - if (this.logger && this.logger.info) { - this.logger.info( - 'services: skipping cache warm-up due to config.calling.cacheU2C=false' - ); - } + if (this.webex.config?.calling && this.webex.config.calling.cacheU2C === false) { + this.logger.info('services: skipping cache warm-up due to config.calling.cacheU2C=false'); - return false; - } - } catch (e) { - // ignore + return false; } + if (typeof window === 'undefined' || !window.localStorage) { return false; } @@ -1131,19 +1121,19 @@ const Services = WebexPlugin.extend({ try { if (this.webex.credentials?.canAuthorize) { const {credentials} = this.webex; - const currentOrgId = credentials.getOrgId(); + currentOrgId = credentials.getOrgId(); if (cached.orgId && cached.orgId !== currentOrgId) { return false; } } } catch (e) { - // ignore orgId check errors + this.logger.warn('services: error checking orgId', e); } // Ensure cached environment matches current environment try { - const fedramp = !!this?.webex?.config?.fedramp; - const u2cDiscoveryUrl = this?.webex?.config?.services?.discovery?.u2c; + const fedramp = !!this.webex.config?.fedramp; + const u2cDiscoveryUrl = this.webex.config?.services?.discovery?.u2c; const currentEnv = {fedramp, u2cDiscoveryUrl}; if (cached.env) { const sameEnv = @@ -1158,7 +1148,7 @@ const Services = WebexPlugin.extend({ } } } catch (e) { - // ignore env check errors + this.logger.warn('services: error checking environment', e); } const catalog = this._getCatalog(); @@ -1167,11 +1157,10 @@ const Services = WebexPlugin.extend({ const getIntendedPreauthSelection = () => { try { if (this.webex.credentials?.canAuthorize) { - const orgId = this.webex.credentials.getOrgId(); - if (orgId) { + if (currentOrgId) { return { selectionType: 'orgId', - selectionValue: orgId, + selectionValue: currentOrgId, }; } } @@ -1212,9 +1201,7 @@ const Services = WebexPlugin.extend({ if (g === 'preauth' && meta) { // For proximity-based selection, always fetch fresh to respect IP/region changes if (meta.selectionType === 'mode') { - if (this.logger && this.logger.info) { - this.logger.info('services: skipping preauth cache warm for proximity mode'); - } + this.logger.info('services: skipping preauth cache warm for proximity mode'); return; } @@ -1226,9 +1213,7 @@ const Services = WebexPlugin.extend({ intended.selectionValue === meta.selectionValue; if (!matches) { - if (this.logger && this.logger.info) { - this.logger.info('services: skipping preauth cache warm due to selection mismatch'); - } + this.logger.info('services: skipping preauth cache warm due to selection mismatch'); return; } diff --git a/packages/@webex/webex-core/test/unit/spec/services/services.js b/packages/@webex/webex-core/test/unit/spec/services/services.js index c73de474a3c..eda4a87c7b1 100644 --- a/packages/@webex/webex-core/test/unit/spec/services/services.js +++ b/packages/@webex/webex-core/test/unit/spec/services/services.js @@ -1043,6 +1043,141 @@ describe('webex-core', () => { assert.isTrue(fetchSpy.called, 'forceRefresh should bypass cache short-circuit'); fetchSpy.restore(); }); + + it('stores selection metadata and env on cache write for preauth', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + // arrange config for env fingerprint + services.webex.config = services.webex.config || {}; + services.webex.config.services = services.webex.config.services || {discovery: {}}; + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + + // write cache with meta + await services._cacheCatalog( + 'preauth', + {serviceLinks: {}, hostCatalog: {}}, + {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'} + ); + + const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1); + assert.isString(raw); + const parsed = JSON.parse(raw); + assert.equal(parsed.orgId, undefined, 'orgId not set without credentials'); + assert.deepEqual(parsed.env, { + fedramp: false, + u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1', + }); + assert.isObject(parsed.preauth); + assert.deepEqual(parsed.preauth.meta, { + selectionType: 'orgId', + selectionValue: 'urn:EXAMPLE:org', + }); + }); + + it('warms preauth from cache when selection meta matches intended orgId', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + // stub credentials + services.webex.credentials = { + canAuthorize: true, + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + }; + // cache with matching orgId selection + window.localStorage.setItem( + CATALOG_CACHE_KEY_V1, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {serviceLinks: {}, hostCatalog: {}}, + meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}, + }, + }) + ); + // formatter returns at least one entry to mark ready + services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore(); + sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [ + {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []}, + ]); + + const warmed = await services._loadCatalogFromCache(); + assert.isTrue(warmed); + assert.isTrue(catalog.status.preauth.ready, 'preauth should be warmed on match'); + }); + + it('does not warm preauth when selection meta is proximity mode', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + // cache with proximity mode selection + window.localStorage.setItem( + CATALOG_CACHE_KEY_V1, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {serviceLinks: {}, hostCatalog: {}}, + meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}, + }, + }) + ); + services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore(); + sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [ + {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []}, + ]); + + const warmed = await services._loadCatalogFromCache(); + // function returns true if overall cache path succeeded; we only verify group readiness + assert.isFalse(catalog.status.preauth.ready, 'preauth should not warm for proximity mode'); + }); + + it('does not warm preauth when selection meta mismatches intended selection', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + // authorized with org X + services.webex.credentials = { + canAuthorize: true, + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + }; + // cache points to a different org + window.localStorage.setItem( + CATALOG_CACHE_KEY_V1, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {serviceLinks: {}, hostCatalog: {}}, + meta: {selectionType: 'orgId', selectionValue: 'urn:DIFF:org'}, + }, + }) + ); + services._formatReceivedHostmap.restore && services._formatReceivedHostmap.restore(); + sinon.stub(services, '_formatReceivedHostmap').callsFake(() => [ + {name: 'hydra', defaultUrl: 'https://api.ciscospark.com/v1', hosts: []}, + ]); + + await services._loadCatalogFromCache(); + assert.isFalse(catalog.status.preauth.ready, 'preauth should not warm on selection mismatch'); + }); + + it('skips warming when environment fingerprint mismatches', async () => { + const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap'; + // cached env differs from current env (different U2C URL) + window.localStorage.setItem( + CATALOG_CACHE_KEY_V1, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.other.com/u2c/api/v1'}, + preauth: {hostMap: {serviceLinks: {}, hostCatalog: {}}, meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}}, + }) + ); + // current env + services.webex.config = services.webex.config || {}; + services.webex.config.services = services.webex.config.services || {discovery: {}}; + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + + const warmed = await services._loadCatalogFromCache(); + assert.isFalse(warmed, 'env mismatch should skip warm and return false'); + assert.isFalse(catalog.status.preauth.ready); + assert.isFalse(catalog.status.postauth.ready); + }); }); }); }); From ea1306a5179be0014a311c33b9545dde5d1f8217 Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 10 Dec 2025 22:20:29 +0530 Subject: [PATCH 5/5] fix(services): code cleanup --- .../src/lib/services-v2/services-v2.ts | 46 ++++++++----------- .../webex-core/src/lib/services/services.js | 38 ++++++--------- .../test/unit/spec/services-v2/services-v2.ts | 18 ++++---- 3 files changed, 44 insertions(+), 58 deletions(-) diff --git a/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts b/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts index 85090d477ca..40591d651da 100644 --- a/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts +++ b/packages/@webex/webex-core/src/lib/services-v2/services-v2.ts @@ -261,11 +261,11 @@ const Services = WebexPlugin.extend({ if (key) { selectionMeta = { selectionType: key, - selectionValue: (formattedQuery as any)[key], + selectionValue: formattedQuery[key], }; } } catch { - // ignore + this.logger.warn('services: error building selection meta'); } } this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta); @@ -1015,7 +1015,7 @@ const Services = WebexPlugin.extend({ (window as any).localStorage.setItem(CATALOG_CACHE_KEY_V2, JSON.stringify(updated)); } } catch { - // ignore cache errors + this.logger.warn('services: error caching catalog'); } }, @@ -1047,7 +1047,7 @@ const Services = WebexPlugin.extend({ try { this.clearCatalogCache(); } catch { - // ignore + this.logger.warn('services: error clearing catalog cache'); } return false; @@ -1063,7 +1063,7 @@ const Services = WebexPlugin.extend({ } } } catch { - // ignore orgId check errors + this.logger.warn('services: error checking orgId'); } // Ensure cached environment matches current environment @@ -1079,8 +1079,8 @@ const Services = WebexPlugin.extend({ return false; } } - } catch { - // ignore env check errors + } catch (e) { + this.logger.warn('services: error checking environment', e); } const catalog = this._getCatalog(); @@ -1088,28 +1088,22 @@ const Services = WebexPlugin.extend({ // Helper: compute intended preauth selection based on current context const getIntendedPreauthSelection = () => { - try { - if (this.webex.credentials?.canAuthorize) { - if (currentOrgId) { - return {selectionType: 'orgId', selectionValue: currentOrgId}; - } + if (this.webex.credentials?.canAuthorize) { + if (currentOrgId) { + return {selectionType: 'orgId', selectionValue: currentOrgId}; } - } catch { - // ignore } + const emailConfig = this.webex.config && this.webex.config.email; - try { - if (typeof emailConfig === 'string' && emailConfig.trim()) { - return { - selectionType: 'emailhash', - selectionValue: sha256(emailConfig.toLowerCase()).toString(), - }; - } - } catch { - // ignore invalid email config, fall through to proximity mode + + if (typeof emailConfig === 'string' && emailConfig.trim()) { + return { + selectionType: 'emailhash', + selectionValue: sha256(emailConfig.toLowerCase()).toString(), + }; } - // fall back to proximity mode when no orgId or email available + // fall back to proximity mode when no orgId or email available return {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}; }; @@ -1164,7 +1158,7 @@ const Services = WebexPlugin.extend({ (window as any).localStorage.removeItem(CATALOG_CACHE_KEY_V2); } } catch { - // ignore + this.logger.warn('services: error clearing catalog cache'); } return Promise.resolve(); @@ -1330,7 +1324,7 @@ const Services = WebexPlugin.extend({ // wait for webex instance to be ready before attempting // to update the service catalogs this.listenToOnce(this.webex, 'ready', async () => { - const warmed = await (this as any)._loadCatalogFromCache(); + const warmed = await this._loadCatalogFromCache(); if (warmed) { catalog.isReady = true; diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js index 801bc15ade2..c1af94fcf8b 100644 --- a/packages/@webex/webex-core/src/lib/services/services.js +++ b/packages/@webex/webex-core/src/lib/services/services.js @@ -1154,9 +1154,7 @@ const Services = WebexPlugin.extend({ cached.env.fedramp === currentEnv.fedramp && cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl; if (!sameEnv) { - if (this.logger && this.logger.info) { - this.logger.info('services: skipping cache warm due to environment mismatch'); - } + this.logger.info('services: skipping cache warm due to environment mismatch'); return false; } @@ -1169,28 +1167,22 @@ const Services = WebexPlugin.extend({ // Helper: compute intended preauth selection based on current context const getIntendedPreauthSelection = () => { - try { - if (this.webex.credentials?.canAuthorize) { - if (currentOrgId) { - return { - selectionType: 'orgId', - selectionValue: currentOrgId, - }; - } - } - } catch (e) { - // ignore - } - const emailConfig = this.webex.config && this.webex.config.email; - try { - if (typeof emailConfig === 'string' && emailConfig.trim()) { + if (this.webex.credentials?.canAuthorize) { + if (currentOrgId) { return { - selectionType: 'emailhash', - selectionValue: sha256(emailConfig.toLowerCase()).toString(), + selectionType: 'orgId', + selectionValue: currentOrgId, }; } - } catch (e) { - // ignore invalid email config, fall through to proximity mode + } + + const emailConfig = this.webex.config && this.webex.config.email; + + if (typeof emailConfig === 'string' && emailConfig.trim()) { + return { + selectionType: 'emailhash', + selectionValue: sha256(emailConfig.toLowerCase()).toString(), + }; } // fall back to proximity mode when no orgId or email available @@ -1260,7 +1252,7 @@ const Services = WebexPlugin.extend({ window.localStorage.removeItem(CATALOG_CACHE_KEY_V1); } } catch (e) { - // ignore + this.logger.warn('services: error clearing catalog cache', e); } return Promise.resolve(); diff --git a/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.ts b/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.ts index 3ec3ef0e3bd..77d8679af42 100644 --- a/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.ts +++ b/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.ts @@ -907,7 +907,7 @@ describe('webex-core', () => { const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); // Act - const warmed = await (services as any)._loadCatalogFromCache(); + const warmed = await services._loadCatalogFromCache(); // Assert assert.isTrue(warmed); @@ -915,7 +915,7 @@ describe('webex-core', () => { spy.calledWith('preauth', [], '1'), 'expected preauth to be warmed when selection matches' ); - (spy as any).restore && (spy as any).restore(); + spy.restore && spy.restore(); }); it('does not warm preauth when selection meta is proximity mode', async () => { @@ -936,7 +936,7 @@ describe('webex-core', () => { const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); // Act - const warmed = await (services as any)._loadCatalogFromCache(); + const warmed = await services._loadCatalogFromCache(); // Assert: overall warm-up succeeds, but preauth is skipped assert.isTrue(warmed); @@ -944,7 +944,7 @@ describe('webex-core', () => { spy.calledWith('preauth', sinon.match.any, sinon.match.any), 'expected preauth not to be warmed for proximity mode' ); - (spy as any).restore && (spy as any).restore(); + spy.restore && spy.restore(); }); it('does not warm preauth when selection meta mismatches intended selection', async () => { @@ -968,14 +968,14 @@ describe('webex-core', () => { ); const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); - const warmed = await (services as any)._loadCatalogFromCache(); + const warmed = await services._loadCatalogFromCache(); assert.isTrue(warmed); assert.isFalse( spy.calledWith('preauth', sinon.match.any, sinon.match.any), 'expected preauth not to be warmed on selection mismatch' ); - (spy as any).restore && (spy as any).restore(); + spy.restore && spy.restore(); }); it('skips warm entirely when environment fingerprint mismatches', async () => { @@ -995,11 +995,11 @@ describe('webex-core', () => { ); const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); - const warmed = await (services as any)._loadCatalogFromCache(); + const warmed = await services._loadCatalogFromCache(); assert.isFalse(warmed, 'env mismatch should skip warm and return false'); - assert.isFalse((spy as any).called, 'no group should be warmed on env mismatch'); - (spy as any).restore && (spy as any).restore(); + assert.isFalse(spy.called, 'no group should be warmed on env mismatch'); + spy.restore && spy.restore(); }); }); });