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/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index f1e6089a..9317d4c8 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,45 @@ 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(); + }); + + 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 95339055..00a312f7 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -342,10 +342,28 @@ 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. + * + * 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. + */ +const ACCESS_TOKEN_RESOLVER_KEY = Symbol.for('openstack-uicore-foundation.accessTokenResolver'); + +export const setAccessTokenResolver = (resolver) => { + globalThis[ACCESS_TOKEN_RESOLVER_KEY] = typeof resolver === 'function' ? resolver : null; +}; + /** * @returns {Promise<*|undefined>} */ export const getAccessToken = async () => { + 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 => { console.log(`openstack-uicore-foundation::Security::methods::getAccessToken web lock api`, lock); 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/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);