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/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/config.js b/packages/@webex/webex-core/src/config.js index 7e80e2aab52..61def04508f 100644 --- a/packages/@webex/webex-core/src/config.js +++ b/packages/@webex/webex-core/src/config.js @@ -83,6 +83,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 118c4d230fd..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 @@ -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 @@ -110,6 +113,7 @@ const Services = WebexPlugin.extend({ * @returns {Array} - An array of `ServiceHost` objects. */ getMobiusClusters(): Array { + this.logger.info('services: fetching mobius clusters'); const clusters: Array = []; const services: Array = this._services || []; @@ -249,6 +253,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[key], + }; + } + } catch { + this.logger.warn('services: error building selection meta'); + } + } + this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta); this.updateCredentialsConfig(); catalog.status[serviceGroup].collecting = false; }) @@ -934,6 +954,216 @@ 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 { + let current: {orgId?: string; env?: {fedramp?: boolean; u2cDiscoveryUrl?: string}} = {}; + let orgId: string | undefined; + try { + // Respect calling.cacheU2C toggle; if disabled, skip writing cache + if (this.webex.config?.calling && this.webex.config.calling.cacheU2C === false) { + return; + } + + 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 = {}; + } + + try { + const {credentials} = this.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.webex.config?.fedramp; + const u2cDiscoveryUrl = this.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 { + this.logger.warn('services: error caching catalog'); + } + }, + + /** + * @private + * Load the catalog from cache and hydrate the in-memory ServiceCatalog. + * @returns {Promise} true if cache was loaded, false otherwise + */ + async _loadCatalogFromCache(): Promise { + let currentOrgId: string | undefined; + try { + // Respect calling.cacheU2C toggle; if disabled, skip using cache + if (this.webex.config?.calling && this.webex.config.calling.cacheU2C === false) { + return false; + } + + 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.clearCatalogCache(); + } catch { + this.logger.warn('services: error clearing catalog cache'); + } + + return false; + } + + // If authorized, ensure cached org matches + try { + if (this.webex.credentials?.canAuthorize) { + const {credentials} = this.webex; + currentOrgId = credentials.getOrgId(); + if (cached.orgId && cached.orgId !== currentOrgId) { + return false; + } + } + } catch { + this.logger.warn('services: error checking orgId'); + } + + // 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) { + return false; + } + } + } catch (e) { + this.logger.warn('services: error checking environment', e); + } + + const catalog = this._getCatalog(); + const groups: Array = ['preauth', 'signin', 'postauth']; + + // Helper: compute intended preauth selection based on current context + const getIntendedPreauthSelection = () => { + if (this.webex.credentials?.canAuthorize) { + if (currentOrgId) { + return {selectionType: 'orgId', selectionValue: currentOrgId}; + } + } + + 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 + 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 { + this.logger.warn('services: error clearing catalog cache'); + } + + return Promise.resolve(); + }, + /** * @private * Simplified method wrapper for sending a request to get @@ -1093,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._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 32f3120dcea..c1af94fcf8b 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 */ /** @@ -165,9 +167,10 @@ const Services = WebexPlugin.extend({ /** * Get all Mobius cluster host entries from the legacy host catalog. - * @returns {Array} + * @returns {Array<{host: string, id: string, ttl: number, priority: number}>} */ getMobiusClusters() { + this.logger.info('services: fetching mobius clusters'); const clusters = []; const hostCatalog = this._hostCatalog || {}; @@ -237,7 +240,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; @@ -291,7 +294,24 @@ const Services = WebexPlugin.extend({ forceRefresh, }) .then((serviceHostMap) => { - catalog.updateServiceUrls(serviceGroup, serviceHostMap); + const formattedServiceHostMap = this._formatReceivedHostmap(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; }) @@ -1008,7 +1028,234 @@ 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 + * @param {object} [meta] - Optional selection metadata used to validate cache reuse + * @returns {Promise} + * + */ + async _cacheCatalog(serviceGroup, hostMap, meta) { + let current = {}; + let env; + let orgId; + try { + // Respect calling.cacheU2C toggle; if disabled, skip writing cache + 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; + } + + // Persist to localStorage to survive browser refresh + try { + const raw = + typeof window !== 'undefined' && window.localStorage + ? window.localStorage.getItem(CATALOG_CACHE_KEY_V1) + : null; + current = raw ? JSON.parse(raw) : {}; + } catch (e) { + current = {}; + } + + try { + const {credentials} = this.webex; + orgId = credentials.getOrgId(); + } catch (e) { + orgId = current.orgId; + } + + // Capture environment fingerprint to invalidate cache across env changes + 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, + 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.localStorage) { + window.localStorage.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(updated)); + } + } catch (error) { + this.logger.warn('services: error caching catalog', error); + } + }, + + /** + * Load the catalog from cache and hydrate the in-memory ServiceCatalog. + * @returns {Promise} true if cache was loaded, false otherwise + */ + async _loadCatalogFromCache() { + let currentOrgId; + try { + // Respect calling.cacheU2C toggle; if disabled, skip using cache + 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; + } + + if (typeof window === 'undefined' || !window.localStorage) { + return false; + } + const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V1); + const cached = raw ? JSON.parse(raw) : undefined; + 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) { + this.logger.warn('services: error clearing catalog cache', e); + } + + return false; + } + + // If authorized, ensure cached org matches + try { + if (this.webex.credentials?.canAuthorize) { + const {credentials} = this.webex; + currentOrgId = credentials.getOrgId(); + if (cached.orgId && cached.orgId !== currentOrgId) { + return false; + } + } + } catch (e) { + 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 currentEnv = {fedramp, u2cDiscoveryUrl}; + if (cached.env) { + const sameEnv = + cached.env.fedramp === currentEnv.fedramp && + cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl; + if (!sameEnv) { + this.logger.info('services: skipping cache warm due to environment mismatch'); + + return false; + } + } + } catch (e) { + this.logger.warn('services: error checking environment', e); + } + + const catalog = this._getCatalog(); + + // Helper: compute intended preauth selection based on current context + const getIntendedPreauthSelection = () => { + if (this.webex.credentials?.canAuthorize) { + if (currentOrgId) { + return { + selectionType: 'orgId', + selectionValue: currentOrgId, + }; + } + } + + 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 + 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) => { + 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') { + 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) { + this.logger.info('services: skipping preauth cache warm due to selection mismatch'); + + return; + } + } + + if (hostMap) { + const formatted = this._formatReceivedHostmap(hostMap); + catalog.updateServiceUrls(g, formatted); + } + }); + + // Align credentials against warmed catalog + this.updateCredentialsConfig(); + + return true; + } catch (e) { + this.logger.warn('services: error loading catalog from cache', 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) { + this.logger.warn('services: error clearing catalog cache', e); + } + + return Promise.resolve(); }, /** @@ -1088,6 +1335,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'); @@ -1123,7 +1371,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-v2/services-v2.ts b/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.ts index ad37d6d1704..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 @@ -764,6 +764,7 @@ describe('webex-core', () => { ); }); }); + describe('#isValidHost', () => { beforeEach(() => { // Setting up a mock services list @@ -813,5 +814,193 @@ describe('webex-core', () => { assert.isFalse(services.isValidHost([])); }); }); + + describe('U2C catalog cache behavior (v2)', () => { + const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap'; + let windowBackup; + let localStorageBackup; + + const makeLocalStorageShim = () => { + const store = new Map(); + return { + getItem: (k: string) => (store.has(k) ? store.get(k) : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + _store: store, + } as any; + }; + + beforeEach(() => { + // Stub window.localStorage + windowBackup = (global as any).window; + if (!(global as any).window) (global as any).window = {}; + localStorageBackup = (global as any).window.localStorage; + (global as any).window.localStorage = makeLocalStorageShim(); + // default current env + services.webex.config = services.webex.config || {}; + services.webex.config.services = services.webex.config.services || {discovery: {}}; + services.webex.config.services.discovery.u2c = + services.webex.config.services.discovery.u2c || 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = + typeof services.webex.config.fedramp === 'boolean' + ? services.webex.config.fedramp + : false; + }); + + afterEach(() => { + (global as any).window.localStorage = localStorageBackup || undefined; + if (!windowBackup) { + delete (global as any).window; + } else { + (global as any).window = windowBackup; + } + }); + + it('stores selection metadata and env on cache write for preauth', async () => { + // Arrange env + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + + // Act + await (services as any)._cacheCatalog( + 'preauth', + {services: [], timestamp: Date.now().toString()}, + {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'} + ); + + // Assert + const raw = (window as any).localStorage.getItem(CATALOG_CACHE_KEY_V2); + assert.isString(raw); + const parsed = JSON.parse(raw as string); + 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 () => { + // Arrange current env and credentials + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + services.webex.credentials = { + canAuthorize: true, + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + }; + // Seed cache + (window as any).localStorage.setItem( + CATALOG_CACHE_KEY_V2, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {services: [], timestamp: '1'}, + meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}, + }, + }) + ); + // Spy updateServiceGroups + const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); + + // Act + const warmed = await services._loadCatalogFromCache(); + + // Assert + assert.isTrue(warmed); + assert.isTrue( + spy.calledWith('preauth', [], '1'), + 'expected preauth to be warmed when selection matches' + ); + spy.restore && spy.restore(); + }); + + it('does not warm preauth when selection meta is proximity mode', async () => { + // Arrange env + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + (window as any).localStorage.setItem( + CATALOG_CACHE_KEY_V2, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {services: [], timestamp: '1'}, + meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'}, + }, + }) + ); + const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); + + // Act + const warmed = await services._loadCatalogFromCache(); + + // Assert: overall warm-up succeeds, but preauth is skipped + assert.isTrue(warmed); + assert.isFalse( + spy.calledWith('preauth', sinon.match.any, sinon.match.any), + 'expected preauth not to be warmed for proximity mode' + ); + spy.restore && spy.restore(); + }); + + it('does not warm preauth when selection meta mismatches intended selection', async () => { + // Arrange env and credentials + services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1'; + services.webex.config.fedramp = false; + services.webex.credentials = { + canAuthorize: true, + getOrgId: sinon.stub().returns('urn:EXAMPLE:org'), + }; + (window as any).localStorage.setItem( + CATALOG_CACHE_KEY_V2, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'}, + preauth: { + hostMap: {services: [], timestamp: '1'}, + meta: {selectionType: 'orgId', selectionValue: 'urn:DIFF:org'}, + }, + }) + ); + const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); + + 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.restore && spy.restore(); + }); + + it('skips warm entirely when environment fingerprint mismatches', async () => { + // Cached env differs from current env + services.webex.config.services.discovery.u2c = 'https://u2c.current.com/u2c/api/v1'; + services.webex.config.fedramp = false; + (window as any).localStorage.setItem( + CATALOG_CACHE_KEY_V2, + JSON.stringify({ + cachedAt: Date.now(), + env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.cached.com/u2c/api/v1'}, + preauth: { + hostMap: {services: [], timestamp: '1'}, + meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}, + }, + }) + ); + const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups'); + + const warmed = await services._loadCatalogFromCache(); + + assert.isFalse(warmed, 'env mismatch should skip warm and return false'); + assert.isFalse(spy.called, 'no group should be warmed on env mismatch'); + spy.restore && spy.restore(); + }); + }); }); }); 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 5c29ccd23d1..f0b9e878860 100644 --- a/packages/@webex/webex-core/test/unit/spec/services/services.js +++ b/packages/@webex/webex-core/test/unit/spec/services/services.js @@ -349,8 +349,6 @@ describe('webex-core', () => { const mapResult = await services._fetchNewServiceHostmap({from: 'limited'}); - assert.deepEqual(mapResult, mapResponse); - assert.calledOnceWithExactly(services.request, { method: 'GET', service: 'u2c', @@ -839,19 +837,22 @@ describe('webex-core', () => { assert.equal(webex.config.credentials.authorizeUrl, authUrl); }); }); - + describe('#getMobiusClusters', () => { it('returns unique mobius host entries from hostCatalog', () => { // Arrange: two hostCatalog keys, with duplicate mobius host across keys services._hostCatalog = { - 'mobius-a.webex.com': [ - {host: 'mobius-a.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:mobius'}, - {host: 'mobius-b.webex.com', ttl: -1, priority: 10, id: 'urn:TEAM:xyz:mobius'}, - {host: 'ignore.webex.com', ttl: -1, priority: 1, id: 'urn:TEAM:abc:wdm'}, // not mobius - ], - 'dup-entry-key': [ - {host: 'mobius-a.webex.com', ttl: -1, priority: 7, id: 'urn:TEAM:xyz:mobius'}, // duplicate host - ], + 'mobius-us-east-2.prod.infra.webex.com': [ + {host: 'mobius-us-east-2.prod.infra.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:mobius'}, + {host: 'mobius-eu-central-1.prod.infra.webex.com', ttl: -1, priority: 10, id: 'urn:TEAM:xyz:mobius'}, + ], + + 'mobius-eu-central-1.prod.infra.webex.com': [ + {host: 'mobius-us-east-2.prod.infra.webex.com', ttl: -1, priority: 7, id: 'urn:TEAM:xyz:mobius'}, // duplicate host + ], + 'wdm-a.webex.com' : [ + {host: 'wdm-a.webex.com', ttl: -1, priority: 5, id: 'urn:TEAM:xyz:wdm'}, + ] }; // Act @@ -862,12 +863,13 @@ describe('webex-core', () => { assert.deepEqual( clusters.map(({host, id, ttl, priority}) => ({host, id, ttl, priority})), [ - {host: 'mobius-a.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 5}, - {host: 'mobius-b.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 10}, + {host: 'mobius-us-east-2.prod.infra.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 5}, + {host: 'mobius-eu-central-1.prod.infra.webex.com', id: 'urn:TEAM:xyz:mobius', ttl: -1, priority: 10}, ] ); }); }); + describe('#isValidHost', () => { beforeEach(() => { // Setting up a mock host catalog @@ -919,6 +921,322 @@ describe('webex-core', () => { assert.isFalse(services.isValidHost([])); }); }); + + describe('U2C catalog cache behavior', () => { + let webex; + let services; + let catalog; + let localStorageBackup; + let windowBackup; + + 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 + 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(); + + // 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(() => { + global.window.localStorage = localStorageBackup || undefined; + if (!windowBackup) { + delete global.window; + } else { + global.window = windowBackup; + } + }); + + 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(), + preauth: {serviceLinks: {}, hostCatalog: {}}, + postauth: {serviceLinks: {}, hostCatalog: {}}, + }; + 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 = { + 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', query:{orgId:'urn:EXAMPLE:org'}, forceRefresh: true}); + await services.updateServices({forceRefresh: true}); + + 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'), + }; + // set current env to match cached 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; + // 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); + }); + }); + }); }); /* eslint-enable no-underscore-dangle */