Skip to content
Draft
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 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(<AttendanceTracker {...props} />);
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" }));
});
});
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" })
);
});
});
29 changes: 28 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,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();
});
});
34 changes: 24 additions & 10 deletions src/components/security/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Expand Down
11 changes: 11 additions & 0 deletions src/components/security/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 14 additions & 1 deletion src/components/security/methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/components/security/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
SET_LOGGED_USER,
UPDATE_SESSION_STATE_STATUS,
UPDATE_USER_INFO
} from './actions';
} from './constants';

import IdTokenVerifier from 'idtoken-verifier';

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();
});
});
2 changes: 1 addition & 1 deletion src/utils/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
9 changes: 6 additions & 3 deletions src/utils/query-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
25 changes: 23 additions & 2 deletions webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -191,5 +212,5 @@ module.exports = {
}
]
},
externals: [nodeExternals()]
externals: [nodeExternals(), shareSecurityMethods]
};
Loading