From ae05306aff595082d073d55f99fb8346b5528290 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 24 Aug 2026 14:25:54 -0300 Subject: [PATCH 1/5] feat(security): let a consumer supply the access token via setAccessTokenResolver Add setAccessTokenResolver: when a resolver is registered, getAccessToken delegates to it; otherwise the built-in flow is unchanged. Passing a non-function resets to the built-in. The resolver is module-level state, so externalize security/methods in the webpack build so every uicore lib entry shares one instance and sees the registered resolver. --- src/components/security/methods.js | 13 +++++++++++++ webpack.common.js | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 95339055..b81b754a 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -342,10 +342,23 @@ const _getAccessToken = async () => { return accessToken; } +/** + * Optional resolver for getAccessToken, set via setAccessTokenResolver. When + * present, getAccessToken delegates to it; otherwise the built-in flow runs. + * Pass a non-function (or nothing) to reset to the built-in. + */ +let _resolveAccessToken = null; + +export const setAccessTokenResolver = (resolver) => { + _resolveAccessToken = typeof resolver === 'function' ? resolver : null; +}; + /** * @returns {Promise<*|undefined>} */ export const getAccessToken = async () => { + if (_resolveAccessToken) return _resolveAccessToken(); + if (typeof navigator !== 'undefined' && navigator.locks) { return await navigator.locks.request(GET_TOKEN_SILENTLY_LOCK_KEY, async lock => { console.log(`openstack-uicore-foundation::Security::methods::getAccessToken web lock api`, lock); diff --git a/webpack.common.js b/webpack.common.js index 043bd518..c68d08b9 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -2,6 +2,27 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const path = require('path'); const nodeExternals = require('webpack-node-externals'); +const { name: PKG_NAME } = require('./package.json'); + +// Externalize uicore's OWN token module so every UMD entry shares one instance: +// methods.js is stateful and must be a singleton. Without this, each importer +// (e.g. query-actions) inlines its own copy with separate state. Other internal +// modules are stateless, so they stay inlined. +const METHODS_SRC = path.resolve(__dirname, 'src/components/security/methods.js'); +const METHODS_MODULE = `${PKG_NAME}/lib/security/methods`; + +// Redirect imports of methods.js to the shared lib file. The issuer guard skips +// the methods entry itself (an entry has no issuer), so it still builds its real +// implementation instead of requiring itself. +const shareSecurityMethods = ({ request, context, contextInfo }, cb) => { + if (!contextInfo || !contextInfo.issuer || !request.startsWith('.')) return cb(); + const resolved = path.resolve(context, request); + if (resolved === METHODS_SRC || `${resolved}.js` === METHODS_SRC) { + return cb(null, METHODS_MODULE); + } + cb(); +}; + module.exports = { entry: { // security @@ -182,7 +203,7 @@ module.exports = { output: { path: path.resolve(__dirname, 'lib'), filename: '[name].js', - library: 'openstack-uicore-foundation', + library: PKG_NAME, libraryTarget: 'umd', umdNamedDefine: true, globalObject: 'this', @@ -286,5 +307,5 @@ module.exports = { } ] }, - externals: [nodeExternals()] + externals: [nodeExternals(), shareSecurityMethods] }; From 458fb39317bd50627ab19f0f5a0063b84ed61eaf Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 24 Aug 2026 14:33:47 -0300 Subject: [PATCH 2/5] test(security): cover setAccessTokenResolver / getAccessToken delegation Delegates to a registered resolver, a later resolver replaces the previous one, and a non-function argument clears it. --- .../security/__tests__/methods.test.js | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index f1e6089a..79537f4f 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -3,7 +3,7 @@ import { AUTH_ERROR_REFRESH_TOKEN_NETWORK_ERROR, } from '../constants'; -import { refreshAccessToken, retryWithBackoff } from '../methods'; +import { refreshAccessToken, retryWithBackoff, getAccessToken, setAccessTokenResolver } from '../methods'; // Mock utils/methods imports used by security/methods jest.mock('../../../utils/methods', () => ({ @@ -370,3 +370,30 @@ describe('retryWithBackoff', () => { setTimeoutSpy.mockRestore(); }); }); + +describe('setAccessTokenResolver / getAccessToken', () => { + afterEach(() => setAccessTokenResolver(null)); + + it('delegates to a registered resolver', async () => { + const resolver = jest.fn().mockResolvedValue('tok-A'); + setAccessTokenResolver(resolver); + await expect(getAccessToken()).resolves.toBe('tok-A'); + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('a later resolver replaces the previous one', async () => { + setAccessTokenResolver(jest.fn().mockResolvedValue('tok-A')); + const next = jest.fn().mockResolvedValue('tok-B'); + setAccessTokenResolver(next); + await expect(getAccessToken()).resolves.toBe('tok-B'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('a non-function argument clears the resolver (built-in flow runs)', async () => { + const resolver = jest.fn().mockResolvedValue('tok-A'); + setAccessTokenResolver(resolver); + setAccessTokenResolver(undefined); + await getAccessToken().catch(() => {}); + expect(resolver).not.toHaveBeenCalled(); + }); +}); From 83d3a4e36787d31ed2eef7a8d878b9edfc25e6b0 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:26:42 -0300 Subject: [PATCH 3/5] fix(query-actions): report a token failure only through the callback _fetch rejected its promise after already calling back with the error. The query* functions never await that promise, so the rejection only surfaced as an unhandled rejection. Return instead. Also guard the 404 branch so a missing response or callback cannot throw. --- src/utils/query-actions.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/utils/query-actions.js b/src/utils/query-actions.js index fe35ff1c..6f0ebb95 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -31,8 +31,8 @@ const _fetchPublic = async (endpoint, callback, options = {}) => { callback(json.data); }) .catch(response => { - const code = response.status; - if (code === 404) callback([]); + const code = response && response.status; + if (code === 404 && typeof callback === 'function') callback([]); return response; }) .catch(fetchErrorHandler); @@ -68,9 +68,12 @@ const _fetch = async (endpoint, callback, options = {}) => { try { accessToken = await getAccessToken(); } catch (e) { + // The caller is told through its callback; the query* functions do not + // await this promise, so rejecting here would only surface as an + // unhandled rejection. if(typeof callback === 'function') callback(e); - return Promise.reject(); + return; } endpoint.addQuery('access_token', accessToken); From cfd9def069977a178a055f7df3e5432dfe7a6a32 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:26:42 -0300 Subject: [PATCH 4/5] test(security): cover the access-token resolver at its internal callers With a resolver registered, query-actions, getUserInfo and AttendanceTracker send the resolver's token, and a resolver failure in query-actions reaches the caller's callback without a request. --- .../__tests__/attendance-tracker.test.js | 56 +++++++++++++++++ .../security/__tests__/get-user-info.test.js | 50 ++++++++++++++++ src/utils/__tests__/query-actions.test.js | 60 +++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/components/__tests__/attendance-tracker.test.js create mode 100644 src/components/security/__tests__/get-user-info.test.js create mode 100644 src/utils/__tests__/query-actions.test.js diff --git a/src/components/__tests__/attendance-tracker.test.js b/src/components/__tests__/attendance-tracker.test.js new file mode 100644 index 00000000..0b2edc50 --- /dev/null +++ b/src/components/__tests__/attendance-tracker.test.js @@ -0,0 +1,56 @@ +/** + * AttendanceTracker reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what the metrics calls send. + * superagent is mocked; the token path is real. + */ +import React from "react"; +import { render, act } from "@testing-library/react"; +import http from "superagent/lib/client"; +import { setAccessTokenResolver } from "../security/methods"; +import AttendanceTracker from "../attendance-tracker"; + +jest.mock("superagent/lib/client", () => { + const end = jest.fn(); + const send = jest.fn(() => ({ end })); + return { put: jest.fn(() => ({ send })), post: jest.fn(() => ({ send })), __send: send }; +}); + +const flush = async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe("AttendanceTracker with an access-token resolver", () => { + const props = { apiBaseUrl: "https://api.test", summitId: 13, sourceId: 7, sourceName: "EVENT" }; + + beforeEach(() => { + jest.clearAllMocks(); + navigator.sendBeacon = jest.fn(); + }); + + afterEach(() => setAccessTokenResolver(null)); + + test("enter, leave and the unload beacon all send the resolver's token", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + + const { unmount } = render(); + await act(flush); + expect(http.put).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/enter"); + expect(http.__send).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "RES-TOK", type: "EVENT", source_id: 7 }) + ); + + await act(async () => { + window.dispatchEvent(new Event("beforeunload")); + await flush(); + }); + const beaconUrl = navigator.sendBeacon.mock.calls[0][0]; + expect(beaconUrl).toContain("/api/v1/summits/13/metrics/leave?access_token=RES-TOK"); + + jest.clearAllMocks(); + unmount(); + await act(flush); + expect(http.post).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/leave"); + expect(http.__send).toHaveBeenCalledWith(expect.objectContaining({ access_token: "RES-TOK" })); + }); +}); diff --git a/src/components/security/__tests__/get-user-info.test.js b/src/components/security/__tests__/get-user-info.test.js new file mode 100644 index 00000000..669fb02e --- /dev/null +++ b/src/components/security/__tests__/get-user-info.test.js @@ -0,0 +1,50 @@ +/** + * getUserInfo reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what the /members/me request sends. + * The request pipeline (utils/actions) is mocked; the token path is real. + */ +jest.mock("../../../utils/actions", () => ({ + getRequest: jest.fn(), + createAction: jest.fn((t) => ({ type: t })), + authErrorHandler: jest.fn(), + showMessage: jest.fn(() => () => {}), + startLoading: jest.fn(() => ({ type: "START_LOADING" })), + stopLoading: jest.fn(() => ({ type: "STOP_LOADING" })), +})); +jest.mock("../../../utils/methods", () => ({ + buildAPIBaseUrl: jest.fn((p) => `BASE${p}`), + getAllowedUserGroups: jest.fn(() => ""), +})); + +import { getUserInfo } from "../actions"; +import { setAccessTokenResolver } from "../methods"; +import { getRequest } from "../../../utils/actions"; + +describe("getUserInfo with an access-token resolver", () => { + afterEach(() => { + setAccessTokenResolver(null); + jest.clearAllMocks(); + }); + + test("sends the resolver's token to /members/me", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + const withParams = jest.fn(() => jest.fn(() => Promise.resolve())); + getRequest.mockReturnValue(withParams); + const dispatch = jest.fn(); + const getState = jest.fn(() => ({ loggedUserState: { member: null } })); + + await getUserInfo("groups", "", null, null, null)(dispatch, getState); + + expect(resolver).toHaveBeenCalled(); + expect(getRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "BASE/api/v1/members/me", + expect.anything() + ); + expect(withParams).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "RES-TOK", expand: "groups" }) + ); + }); +}); diff --git a/src/utils/__tests__/query-actions.test.js b/src/utils/__tests__/query-actions.test.js new file mode 100644 index 00000000..59baf53a --- /dev/null +++ b/src/utils/__tests__/query-actions.test.js @@ -0,0 +1,60 @@ +/** + * query-actions reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what every query* function sends. + * + * lodash/debounce is mocked to a passthrough so the query* functions run + * synchronously; the debounce delay is unrelated to the token path. + */ +import { setAccessTokenResolver } from "../../components/security/methods"; +import { queryMembers, querySummits } from "../query-actions"; + +jest.mock("lodash/debounce", () => (fn) => fn); + +const flush = async () => { + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe("query-actions with an access-token resolver", () => { + let origFetch; + + beforeEach(() => { + window.API_BASE_URL = "https://api.test"; + origFetch = global.fetch; + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [{ id: 1 }] }) }) + ); + }); + + afterEach(() => { + setAccessTokenResolver(null); + global.fetch = origFetch; + jest.clearAllMocks(); + }); + + test("sends the resolver's token as access_token", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + const cb = jest.fn(); + + queryMembers("x", cb); + await flush(); + + expect(resolver).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + const url = decodeURIComponent(global.fetch.mock.calls[0][0]); + expect(url).toContain("https://api.test/api/v1/members"); + expect(url).toContain("access_token=RES-TOK"); + expect(cb).toHaveBeenCalledWith([{ id: 1 }]); + }); + + test("a resolver failure calls back with the error and does not fetch", async () => { + setAccessTokenResolver(() => Promise.reject(new Error("no session"))); + const cb = jest.fn(); + + querySummits("x", cb); + await flush(); + + expect(cb).toHaveBeenCalledWith(expect.any(Error)); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); From eb08c62f72ea8ced27790fa437e06ab56f6ab437 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 3 Sep 2026 11:48:24 -0300 Subject: [PATCH 5/5] refactor(security): share the access-token resolver through a Symbol.for global slot The resolver moves from module state to globalThis[Symbol.for('openstack-uicore-foundation.accessTokenResolver')], so every copy of security/methods reads the same slot: bundles that inline it, nested installs of the package, and symlinked dev installs. This drops the webpack externals rule that made the module a cross-bundle singleton; no bundle requires the package by its own name anymore, so yarn link installs keep working and hosts no longer need a single hoisted copy. --- .../security/__tests__/methods.test.js | 15 +++++++++++ src/components/security/methods.js | 11 +++++--- webpack.common.js | 25 ++----------------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index 79537f4f..9317d4c8 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -396,4 +396,19 @@ describe('setAccessTokenResolver / getAccessToken', () => { await getAccessToken().catch(() => {}); expect(resolver).not.toHaveBeenCalled(); }); + + it('a resolver registered on one module copy is visible to a second copy', async () => { + // The slot rides globalThis under Symbol.for, so duplicate installs of + // the package (nested node_modules, symlinked dev installs) share it. + const resolver = jest.fn().mockResolvedValue('tok-shared'); + setAccessTokenResolver(resolver); + + let secondCopy; + jest.isolateModules(() => { + secondCopy = require('../methods'); + }); + expect(secondCopy.getAccessToken).not.toBe(getAccessToken); + await expect(secondCopy.getAccessToken()).resolves.toBe('tok-shared'); + expect(resolver).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/security/methods.js b/src/components/security/methods.js index b81b754a..00a312f7 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -346,18 +346,23 @@ const _getAccessToken = async () => { * Optional resolver for getAccessToken, set via setAccessTokenResolver. When * present, getAccessToken delegates to it; otherwise the built-in flow runs. * Pass a non-function (or nothing) to reset to the built-in. + * + * The slot lives on globalThis under a Symbol.for key so every copy of this + * module shares it: bundles that inlined methods.js, nested installs of the + * package, and symlinked dev installs all read the same registry entry. */ -let _resolveAccessToken = null; +const ACCESS_TOKEN_RESOLVER_KEY = Symbol.for('openstack-uicore-foundation.accessTokenResolver'); export const setAccessTokenResolver = (resolver) => { - _resolveAccessToken = typeof resolver === 'function' ? resolver : null; + globalThis[ACCESS_TOKEN_RESOLVER_KEY] = typeof resolver === 'function' ? resolver : null; }; /** * @returns {Promise<*|undefined>} */ export const getAccessToken = async () => { - if (_resolveAccessToken) return _resolveAccessToken(); + const resolveAccessToken = globalThis[ACCESS_TOKEN_RESOLVER_KEY]; + if (resolveAccessToken) return resolveAccessToken(); if (typeof navigator !== 'undefined' && navigator.locks) { return await navigator.locks.request(GET_TOKEN_SILENTLY_LOCK_KEY, async lock => { diff --git a/webpack.common.js b/webpack.common.js index c68d08b9..043bd518 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -2,27 +2,6 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const path = require('path'); const nodeExternals = require('webpack-node-externals'); -const { name: PKG_NAME } = require('./package.json'); - -// Externalize uicore's OWN token module so every UMD entry shares one instance: -// methods.js is stateful and must be a singleton. Without this, each importer -// (e.g. query-actions) inlines its own copy with separate state. Other internal -// modules are stateless, so they stay inlined. -const METHODS_SRC = path.resolve(__dirname, 'src/components/security/methods.js'); -const METHODS_MODULE = `${PKG_NAME}/lib/security/methods`; - -// Redirect imports of methods.js to the shared lib file. The issuer guard skips -// the methods entry itself (an entry has no issuer), so it still builds its real -// implementation instead of requiring itself. -const shareSecurityMethods = ({ request, context, contextInfo }, cb) => { - if (!contextInfo || !contextInfo.issuer || !request.startsWith('.')) return cb(); - const resolved = path.resolve(context, request); - if (resolved === METHODS_SRC || `${resolved}.js` === METHODS_SRC) { - return cb(null, METHODS_MODULE); - } - cb(); -}; - module.exports = { entry: { // security @@ -203,7 +182,7 @@ module.exports = { output: { path: path.resolve(__dirname, 'lib'), filename: '[name].js', - library: PKG_NAME, + library: 'openstack-uicore-foundation', libraryTarget: 'umd', umdNamedDefine: true, globalObject: 'this', @@ -307,5 +286,5 @@ module.exports = { } ] }, - externals: [nodeExternals(), shareSecurityMethods] + externals: [nodeExternals()] };