Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/__tests__/attendance-tracker.test.js
Original file line number Diff line number Diff line change
@@ -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(<AttendanceTracker {...props} />);
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" }));
});
});
50 changes: 50 additions & 0 deletions src/components/security/__tests__/get-user-info.test.js
Original file line number Diff line number Diff line change
@@ -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" })
);
});
});
44 changes: 43 additions & 1 deletion src/components/security/__tests__/methods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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);
});
});
18 changes: 18 additions & 0 deletions src/components/security/methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini The resolver is shared only across bundles that resolve to the same installed copy of the package, so the singleton guarantee (and the "every uicore lib entry shares one methods instance" statement in the description) holds only when the host's dependency tree hoists a single openstack-uicore-foundation.

Concrete case: in the current event-site tree, my-orders-tickets-widget, full-schedule-widget and schedule-filter-widget each carry a nested node_modules/openstack-uicore-foundation (they pin uicore as an exact-version dependency), and my-orders-tickets-widget/dist/index.js does require("openstack-uicore-foundation/lib/security/methods"), which resolves to its nested copy. In a cookie-only host, the host registers the resolver on the top-level copy while the widget's calls hit a copy whose resolver is still null, fall into the localStorage flow and throw AUTH_ERROR_MISSING_AUTH_INFO.

Suggested: no code change here. State the precondition in this JSDoc and in the PR description: the host must end up with a single hoisted copy of the package, so widgets should declare uicore as a peerDependency only, not as a pinned dependency. That way the host knows to dedupe before relying on the resolver.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed at the root instead of documenting it: since bd79348 the resolver no longer lives in module state. It lives on globalThis under Symbol.for('openstack-uicore-foundation.accessTokenResolver'), so every copy of security/methods reads the same slot, including a nested copy inside a widget's own node_modules. The single-hoisted-copy precondition is gone. The only case that still misses is a nested copy of an OLDER uicore version without this code, and that is equally true of any approach.

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);
Expand Down
60 changes: 60 additions & 0 deletions src/utils/__tests__/query-actions.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 6 additions & 3 deletions src/utils/query-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

endpoint.addQuery('access_token', accessToken);
Expand Down
Loading