diff --git a/src/components/__tests__/attendance-tracker.test.js b/src/components/__tests__/attendance-tracker.test.js new file mode 100644 index 00000000..e41d7b60 --- /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 Enzyme, { mount } from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; +import http from "superagent/lib/client"; +import { setAccessTokenResolver } from "../security/methods"; +import AttendanceTracker from "../attendance-tracker"; + +Enzyme.configure({ adapter: new Adapter() }); + +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 w = mount(); + await 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 w.instance().onBeforeUnload(); + const beaconUrl = navigator.sendBeacon.mock.calls[0][0]; + expect(beaconUrl).toContain("/api/v1/summits/13/metrics/leave?access_token=RES-TOK"); + + jest.clearAllMocks(); + w.unmount(); + await 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/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(); + }); +}); diff --git a/src/components/security/actions.js b/src/components/security/actions.js index 65a84d8d..378d49ce 100644 --- a/src/components/security/actions.js +++ b/src/components/security/actions.js @@ -19,16 +19,30 @@ import { getAccessToken, storeAuthInfo, initLogOut} from './methods'; /** * @ignore */ -export const SET_LOGGED_USER = 'SET_LOGGED_USER'; -export const LOGOUT_USER = 'LOGOUT_USER'; -export const REQUEST_USER_INFO = 'REQUEST_USER_INFO'; -export const RECEIVE_USER_INFO = 'RECEIVE_USER_INFO'; -export const CLEAR_SESSION_STATE = 'CLEAR_SESSION_STATE'; -export const UPDATE_SESSION_STATE_STATUS = 'UPDATE_SESSION_STATE_STATUS'; -export const SESSION_STATE_STATUS_UNCHANGED = 'unchanged'; -export const SESSION_STATE_STATUS_CHANGED = 'changed'; -export const SESSION_STATE_STATUS_ERROR = 'error'; -export const UPDATE_USER_INFO = 'UPDATE_USER_INFO'; +import { + SET_LOGGED_USER, + LOGOUT_USER, + REQUEST_USER_INFO, + RECEIVE_USER_INFO, + CLEAR_SESSION_STATE, + UPDATE_SESSION_STATE_STATUS, + SESSION_STATE_STATUS_UNCHANGED, + SESSION_STATE_STATUS_CHANGED, + SESSION_STATE_STATUS_ERROR, + UPDATE_USER_INFO, +} from './constants'; +export { + SET_LOGGED_USER, + LOGOUT_USER, + REQUEST_USER_INFO, + RECEIVE_USER_INFO, + CLEAR_SESSION_STATE, + UPDATE_SESSION_STATE_STATUS, + SESSION_STATE_STATUS_UNCHANGED, + SESSION_STATE_STATUS_CHANGED, + SESSION_STATE_STATUS_ERROR, + UPDATE_USER_INFO, +}; export const onUserAuth = (accessToken, idToken, sessionState, expiresIn = 0, refreshToken = null) => (dispatch) => { diff --git a/src/components/security/constants.js b/src/components/security/constants.js index 2900fa7a..ca1a9583 100644 --- a/src/components/security/constants.js +++ b/src/components/security/constants.js @@ -8,3 +8,14 @@ export const AUTH_ERROR_ID_TOKEN_INVALID = 'AUTH_ERROR_ID_TOKEN_INVALID'; export const AUTH_ERROR_MISSING_OTP_PARAM = 'AUTH_ERROR_MISSING_OTP_PARAM'; export const AUTH_ERROR_MISSING_PKCE_PARAM = 'AUTH_ERROR_MISSING_PKCE_PARAM'; export const AUTH_ERROR_MISSING_NONCE_PARAM = 'AUTH_ERROR_MISSING_NONCE_PARAM'; + +export const SET_LOGGED_USER = 'SET_LOGGED_USER'; +export const LOGOUT_USER = 'LOGOUT_USER'; +export const REQUEST_USER_INFO = 'REQUEST_USER_INFO'; +export const RECEIVE_USER_INFO = 'RECEIVE_USER_INFO'; +export const CLEAR_SESSION_STATE = 'CLEAR_SESSION_STATE'; +export const UPDATE_SESSION_STATE_STATUS = 'UPDATE_SESSION_STATE_STATUS'; +export const SESSION_STATE_STATUS_UNCHANGED = 'unchanged'; +export const SESSION_STATE_STATUS_CHANGED = 'changed'; +export const SESSION_STATE_STATUS_ERROR = 'error'; +export const UPDATE_USER_INFO = 'UPDATE_USER_INFO'; diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 95339055..c8039f00 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -16,10 +16,10 @@ import Cookies from 'js-cookie' let http = request; import URI from "urijs"; import IdTokenVerifier from "idtoken-verifier"; -import {SET_LOGGED_USER} from "./actions"; import {getRandomBytes, getSHA256} from "../../utils/crypto"; import { + SET_LOGGED_USER, AUTH_ERROR_ACCESS_TOKEN_EXPIRED, AUTH_ERROR_MISSING_AUTH_INFO, AUTH_ERROR_MISSING_REFRESH_TOKEN, @@ -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/src/components/security/reducers.js b/src/components/security/reducers.js index bfffbcca..f9fba8c0 100644 --- a/src/components/security/reducers.js +++ b/src/components/security/reducers.js @@ -19,7 +19,7 @@ import { SET_LOGGED_USER, UPDATE_SESSION_STATE_STATUS, UPDATE_USER_INFO -} from './actions'; +} from './constants'; import IdTokenVerifier from 'idtoken-verifier'; 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(); + }); +}); diff --git a/src/utils/actions.js b/src/utils/actions.js index 1d7ea724..510e2b45 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -19,7 +19,7 @@ let http = request; import Swal from 'sweetalert2'; import T from "i18n-react/dist/i18n-react"; import { isClearingSessionState, setSessionClearingState, getCurrentPathName } from './methods'; -import { CLEAR_SESSION_STATE } from '../components/security/actions'; +import { CLEAR_SESSION_STATE } from '../components/security/constants'; import { doLogin, initLogOut } from '../components/security/methods'; export const GENERIC_ERROR = "Yikes. Something seems to be broken. Our web team has been notified, and we apologize for the inconvenience."; diff --git a/src/utils/query-actions.js b/src/utils/query-actions.js index 18fc0bb0..f52012b7 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -29,8 +29,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); @@ -50,9 +50,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); diff --git a/webpack.common.js b/webpack.common.js index 550ffe4f..d73aa003 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 @@ -98,7 +119,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', @@ -191,5 +212,5 @@ module.exports = { } ] }, - externals: [nodeExternals()] + externals: [nodeExternals(), shareSecurityMethods] };